[
  {
    "path": ".cargo/config.toml",
    "content": "[env]\n# Required by rolldown_workspace crate - points to the rolldown subproject root\nWORKSPACE_DIR = { value = \"rolldown\", relative = true }\n\n[build]\nrustflags = [\"--cfg\", \"tokio_unstable\"] # also update .github/workflows/ci.yml\n\n# fix sqlite build error on linux\n[target.'cfg(target_os = \"linux\")']\nrustflags = [\"--cfg\", \"tokio_unstable\", \"-C\", \"link-args=-Wl,--warn-unresolved-symbols\"]\n\n# Increase stack size on Windows to avoid stack overflow\n[target.'cfg(all(windows, target_env = \"msvc\"))']\nrustflags = [\"--cfg\", \"tokio_unstable\", \"-C\", \"link-arg=/STACK:8388608\"]\n[target.'cfg(all(windows, target_env = \"gnu\"))']\nrustflags = [\"--cfg\", \"tokio_unstable\", \"-C\", \"link-arg=-Wl,--stack,8388608\"]\n\n[unstable]\nbindeps = true\n\n[net]\ngit-fetch-with-cli = true #  use git CLI to authenticate for vite-task git dependencies\n"
  },
  {
    "path": ".claude/agents/cargo-workspace-merger.md",
    "content": "---\nname: cargo-workspace-merger\ndescription: \"Use this agent when you need to merge one Cargo workspace into another, specifically when integrating a subproject's crates and dependencies into a root workspace. This includes tasks like: adding crate path references to workspace members, merging workspace dependency definitions while avoiding duplicates, and ensuring only production dependencies (not unnecessary dev dependencies) are included.\\\\n\\\\n<example>\\\\nContext: The user wants to integrate the rolldown project into their existing Cargo workspace.\\\\nuser: \\\"I need to merge the rolldown Cargo workspace into our root workspace\\\"\\\\nassistant: \\\"I'll use the cargo-workspace-merger agent to handle this integration. This involves analyzing both Cargo.toml files, identifying the crates to add, and merging the necessary dependencies.\\\"\\\\n<Task tool call to launch cargo-workspace-merger agent>\\\\n</example>\\\\n\\\\n<example>\\\\nContext: The user has cloned a Rust project as a subdirectory and wants to integrate it.\\\\nuser: \\\"Can you add all the crates from ./external-lib into our workspace?\\\"\\\\nassistant: \\\"I'll launch the cargo-workspace-merger agent to analyze the external library's workspace structure and merge it into your root Cargo.toml.\\\"\\\\n<Task tool call to launch cargo-workspace-merger agent>\\\\n</example>\"\nmodel: opus\ncolor: yellow\n---\n\nYou are an expert Rust build system engineer specializing in Cargo workspace management and dependency resolution. You have deep knowledge of Cargo.toml structure, workspace inheritance, and dependency deduplication strategies.\n\n## Your Primary Mission\n\nMerge a child Cargo workspace (located in a subdirectory) into a parent root Cargo workspace. This involves two main tasks:\n\n1. **Adding crate references**: Add all crates from the child workspace to the root workspace's `[workspace.dependencies]` section with proper path references.\n\n2. **Merging workspace dependencies**: Combine the child workspace's `[workspace.dependencies]` with the root's dependencies, ensuring no duplicates and only including dependencies actually used by the crates being merged.\n\n## Step-by-Step Process\n\n### Step 1: Analyze the Child Workspace\n\n- Read the child workspace's `Cargo.toml` (e.g., `./rolldown/Cargo.toml`)\n- Identify all workspace members from the `[workspace.members]` section\n- Extract all `[workspace.dependencies]` definitions\n\n### Step 2: Identify Crates to Add\n\n- For each workspace member, locate its `Cargo.toml`\n- Extract the crate name from `[package].name`\n- Build a list of path references in the format: `crate_name = { path = \"./child/crates/crate_name\" }`\n\n### Step 3: Analyze Dependency Usage\n\n- For each crate in the child workspace, read its `Cargo.toml`\n- Collect all dependencies from `[dependencies]`, `[dev-dependencies]`, and `[build-dependencies]`\n- Focus on dependencies that reference `workspace = true` - these need the workspace-level definition\n- Create a set of actually-used workspace dependencies\n\n### Step 4: Filter and Merge Dependencies\n\n- From the child's `[workspace.dependencies]`, only include those that are actually used by the crates\n- Check for conflicts with existing root workspace dependencies:\n  - Same dependency, same version: Skip (already exists)\n  - Same dependency, different version: Flag for manual resolution and suggest keeping the newer version\n- Exclude dev-only dependencies that aren't needed for the merged crates\n\n### Step 5: Update Root Cargo.toml\n\n- Add all crate path references to `[workspace.dependencies]`\n- Add filtered workspace dependencies to `[workspace.dependencies]`\n- Maintain alphabetical ordering within sections for cleanliness\n- Preserve any existing comments and formatting\n\n## Output Format\n\nProvide:\n\n1. A summary of crates being added\n2. A summary of dependencies being merged\n3. Any conflicts or issues requiring manual attention\n4. The exact additions to make to the root `Cargo.toml`\n\n## Quality Checks\n\n- Verify all paths exist before adding references\n- Ensure no duplicate entries are created\n- Validate that merged dependencies don't break existing crates\n- After modifications, suggest running `cargo check --workspace` to verify the merge\n- Use highest compatible semver versions (if not pinned) and merge features in crates\n\n## Important Considerations\n\n- Use `vite_path` types for path operations as per project conventions\n- Dependencies with `path` references in the child workspace may need path adjustments\n- Feature flags on dependencies must be preserved\n- Optional dependencies must maintain their optional status\n- If a dependency exists in both workspaces with different features, merge the feature lists\n\n### Workspace Package Inheritance\n\nChild crates may inherit fields from `[workspace.package]` using `field.workspace = true`. Common inherited fields include:\n\n- `homepage`\n- `repository`\n- `license`\n- `edition`\n- `authors`\n- `rust-version`\n\n**Important**: If the child workspace's `[workspace.package]` defines fields that the root workspace does not, you must add those fields to the root workspace's `[workspace.package]` section. Otherwise, crates that inherit these fields will fail to build with errors like:\n\n```\nerror inheriting `homepage` from workspace root manifest's `workspace.package.homepage`\nCaused by: `workspace.package.homepage` was not defined\n```\n\n**Steps to handle this**:\n\n1. Read the child workspace's `[workspace.package]` section\n2. Compare with the root workspace's `[workspace.package]` section\n3. Add any missing fields to the root workspace (use the root project's own values, not the child's)\n\n## Error Handling\n\n- If a crate path doesn't exist, report it clearly and skip\n- If Cargo.toml parsing fails, provide the specific error\n- If version conflicts exist, list all conflicts before proceeding and ask for guidance\n\n### Crates with Compile-Time Environment Variables\n\nSome crates use `env!()` macros that require compile-time environment variables set via `.cargo/config.toml`. These crates often have `relative = true` paths that only work when building from their original workspace root.\n\n**Example**: `rolldown_workspace` uses `env!(\"WORKSPACE_DIR\")` which is set in `rolldown/.cargo/config.toml`.\n\n**How to handle**:\n\n1. Check child workspace's `.cargo/config.toml` for `[env]` section\n2. If crates use these env vars with `relative = true`, copy those env vars to root `.cargo/config.toml` with paths adjusted to point to the child workspace directory\n3. Example: If child has `WORKSPACE_DIR = { value = \"\", relative = true }`, root should have `WORKSPACE_DIR = { value = \"child-dir\", relative = true }`\n"
  },
  {
    "path": ".claude/agents/monorepo-architect.md",
    "content": "---\nname: monorepo-architect\ndescription: Use this agent when you need architectural guidance for monorepo tooling, particularly for reviewing code organization, module boundaries, and ensuring proper separation of concerns in Rust/Node.js projects. This agent should be invoked after implementing new features or refactoring existing code to validate architectural decisions and placement of functionality.\\n\\nExamples:\\n- <example>\\n  Context: The user has just implemented a new caching mechanism for the monorepo task runner.\\n  user: \"I've added a new caching system to handle task outputs\"\\n  assistant: \"I'll use the monorepo-architect agent to review the architectural decisions and ensure the caching logic is properly placed within the module structure.\"\\n  <commentary>\\n  Since new functionality was added, use the monorepo-architect agent to review the code architecture and module boundaries.\\n  </commentary>\\n</example>\\n- <example>\\n  Context: The user is refactoring the task dependency resolution system.\\n  user: \"I've refactored how we resolve task dependencies across packages\"\\n  assistant: \"Let me invoke the monorepo-architect agent to review the refactored code and ensure proper separation of concerns.\"\\n  <commentary>\\n  After refactoring core functionality, use the monorepo-architect agent to validate architectural decisions.\\n  </commentary>\\n</example>\\n- <example>\\n  Context: The user is adding cross-package communication features.\\n  user: \"I've implemented a new IPC mechanism for packages to communicate during builds\"\\n  assistant: \"I'll use the monorepo-architect agent to review where this IPC logic lives and ensure it doesn't create inappropriate cross-module dependencies.\"\\n  <commentary>\\n  When adding features that span multiple modules, use the monorepo-architect agent to prevent architectural violations.\\n  </commentary>\\n</example>\nmodel: opus\ncolor: purple\n---\n\nYou are a senior software architect with deep expertise in Rust and Node.js ecosystems, specializing in monorepo tooling and build systems. You have extensively studied and analyzed the architectures of nx, Turborepo, Rush, and Lage, understanding their design decisions, trade-offs, and implementation patterns.\n\nYour primary responsibility is to review code architecture and ensure that functionality is properly organized within the codebase. You focus on:\n\n**Core Architectural Principles:**\n\n- Single Responsibility: Each module, file, and function should have one clear purpose\n- Separation of Concerns: Business logic, I/O operations, and configuration should be clearly separated\n- Module Boundaries: Enforce clean interfaces between modules, preventing tight coupling\n- Dependency Direction: Dependencies should flow in one direction, typically from high-level to low-level modules\n\n**When reviewing code, you will:**\n\n1. **Analyze Module Structure**: Examine where new functionality has been placed and determine if it belongs there based on the module's responsibility. Look for code that crosses logical boundaries or mixes concerns.\n\n2. **Identify Architectural Violations**:\n   - Cross-module responsibilities where one module is doing work that belongs to another\n   - Circular dependencies or bidirectional coupling\n   - Business logic mixed with I/O operations\n   - Configuration logic scattered across multiple modules\n   - Violation of the dependency inversion principle\n\n3. **Suggest Proper Placement**: When you identify misplaced functionality, provide specific recommendations:\n   - Identify the correct module/file where the code should reside\n   - Explain why the current placement violates architectural principles\n   - Suggest how to refactor without breaking existing functionality\n   - Consider the impact on testing and maintainability\n\n4. **Reference Industry Standards**: Draw from your knowledge of nx, Turborepo, Rush, and Lage to:\n   - Compare architectural decisions with proven patterns from these tools\n   - Highlight when a different approach might be more scalable or maintainable\n   - Suggest battle-tested patterns for common monorepo challenges\n\n5. **Focus on Rust/Node.js Best Practices**:\n   - In Rust: Ensure proper use of ownership, traits for abstraction, and module organization\n   - In Node.js: Validate CommonJS/ESM module patterns, async patterns, and package boundaries\n   - For interop: Review FFI boundaries and data serialization approaches\n\n**Review Methodology:**\n\n1. Start by understanding the intent of the recent changes\n2. Map out the affected modules and their responsibilities\n3. Identify any code that seems out of place or creates inappropriate coupling\n4. Provide a prioritized list of architectural concerns (critical, important, minor)\n5. For each concern, explain the principle being violated and suggest a concrete fix\n\n**Output Format:**\n\nStructure your review as:\n\n- **Summary**: Brief overview of architectural health\n- **Critical Issues**: Must-fix architectural violations that will cause problems\n- **Recommendations**: Suggested improvements with rationale\n- **Positive Patterns**: Acknowledge well-architected decisions\n- **Comparison Notes**: When relevant, note how similar problems are solved in nx/Turborepo/Rush/Lage\n\nYou are pragmatic and understand that perfect architecture must be balanced with delivery speed. Focus on issues that will genuinely impact maintainability, testability, or scalability. Avoid nitpicking and recognize when 'good enough' is appropriate for the current stage of the project.\n\nWhen you lack context about the broader system, ask clarifying questions rather than making assumptions. Your goal is to ensure the codebase remains maintainable and follows established architectural patterns while evolving to meet new requirements.\n"
  },
  {
    "path": ".claude/skills/add-ecosystem-ci/SKILL.md",
    "content": "---\nname: add-ecosystem-ci\ndescription: Add a new ecosystem-ci test case for testing real-world projects against vite-plus\nallowed-tools: Bash, Read, Edit, Write, WebFetch, AskUserQuestion\n---\n\n# Add Ecosystem-CI Test Case\n\nAdd a new ecosystem-ci test case following this process:\n\n## Step 1: Get Repository Information\n\nAsk the user for the GitHub repository URL if not provided as argument: $ARGUMENTS\n\nUse GitHub CLI to get repository info:\n\n```bash\ngh api repos/OWNER/REPO --jq '.default_branch'\ngh api repos/OWNER/REPO/commits/BRANCH --jq '.sha'\n```\n\n## Step 2: Auto-detect Project Configuration\n\n### 2.1 Check for Subdirectory\n\nFetch the repository's root to check if the main package.json is in a subdirectory (like `web/`, `app/`, `frontend/`).\n\n### 2.2 Check if Project Already Uses Vite-Plus\n\nCheck the project's root `package.json` for `vite-plus` in `dependencies` or `devDependencies`. If the project already uses vite-plus, set `forceFreshMigration: true` in `repo.json`. This tells `patch-project.ts` to set `VITE_PLUS_FORCE_MIGRATE=1` so `vp migrate` forces full dependency rewriting instead of skipping with \"already using Vite+\".\n\n### 2.3 Auto-detect Commands from GitHub Workflows\n\nFetch the project's GitHub workflow files to detect available commands:\n\n```bash\n# List workflow files\ngh api repos/OWNER/REPO/contents/.github/workflows --jq '.[].name'\n\n# Fetch workflow content (for each .yml/.yaml file)\ngh api repos/OWNER/REPO/contents/.github/workflows/ci.yml --jq '.content' | base64 -d\n```\n\nLook for common patterns in workflow files:\n\n- `pnpm run <command>` / `npm run <command>` / `yarn <command>`\n- Commands like: `lint`, `build`, `test`, `type-check`, `typecheck`, `format`, `format:check`\n- Map detected commands to `vp` equivalents: `vp run lint`, `vp run build`, etc.\n\n### 2.4 Ask User to Confirm\n\nPresent the auto-detected configuration and ask user to confirm or modify:\n\n- Which directory contains the main package.json? (auto-detected or manual)\n- What Node.js version to use? (22 or 24, try to detect from workflow)\n- Which commands to run? (show detected commands as multi-select options)\n- Which OS to run on? (both, ubuntu-only, windows-only) - default: both\n\n## Step 3: Update Files\n\n1. **Add to `ecosystem-ci/repo.json`**:\n\n   ```json\n   {\n     \"project-name\": {\n       \"repository\": \"https://github.com/owner/repo.git\",\n       \"branch\": \"main\",\n       \"hash\": \"full-commit-sha\",\n       \"directory\": \"web\", // only if subdirectory is needed\n       \"forceFreshMigration\": true // only if project already uses vite-plus\n     }\n   }\n   ```\n\n2. **Add to `.github/workflows/e2e-test.yml`** matrix:\n   ```yaml\n   - name: project-name\n     node-version: 24\n     directory: web # only if subdirectory is needed\n     command: |\n       vp run lint\n       vp run build\n   ```\n\n## Step 4: Verify\n\nTest the clone locally:\n\n```bash\nnode ecosystem-ci/clone.ts project-name\n```\n\n3. **Add OS exclusion to `.github/workflows/e2e-test.yml`** (if not running on both):\n\n   For ubuntu-only:\n\n   ```yaml\n   exclude:\n     - os: windows-latest\n       project:\n         name: project-name\n   ```\n\n   For windows-only:\n\n   ```yaml\n   exclude:\n     - os: ubuntu-latest\n       project:\n         name: project-name\n   ```\n\n## Important Notes\n\n- The `directory` field is optional - only add it if the package.json is not in the project root\n- If `directory` is specified in repo.json, it must also be specified in the workflow matrix\n- `patch-project.ts` automatically handles running `vp migrate` in the correct directory\n- `forceFreshMigration` is required for projects that already have `vite-plus` in their package.json — it sets `VITE_PLUS_FORCE_MIGRATE=1` so `vp migrate` forces full dependency rewriting instead of skipping\n- OS exclusions are added to the existing `exclude` section in the workflow matrix\n"
  },
  {
    "path": ".claude/skills/bump-vite-task/SKILL.md",
    "content": "---\nname: bump-vite-task\ndescription: Bump vite-task git dependency to the latest main commit. Use when you need to update the vite-task crates (fspy, vite_glob, vite_path, vite_str, vite_task, vite_workspace) in vite-plus.\nallowed-tools: Read, Grep, Glob, Edit, Bash, Agent, WebFetch\n---\n\n# Bump vite-task to Latest Main\n\nUpdate the vite-task git dependency in `Cargo.toml` to the latest commit on the vite-task main branch, fix any breaking changes, and create a PR.\n\n## Steps\n\n### 1. Get current and target commits\n\n- Read `Cargo.toml` and find the current `rev = \"...\"` for any vite-task git dependency (e.g., `vite_task`, `vite_path`, `fspy`, `vite_glob`, `vite_str`, `vite_workspace`). They all share the same revision.\n- Get the latest commit hash on vite-task's main branch:\n  ```bash\n  git ls-remote https://github.com/voidzero-dev/vite-task.git refs/heads/main\n  ```\n\n### 2. Update Cargo.toml\n\n- Replace **all** occurrences of the old commit hash with the new one in `Cargo.toml`. There are 6 crate entries that reference the same vite-task revision: `fspy`, `vite_glob`, `vite_path`, `vite_str`, `vite_task`, `vite_workspace`.\n\n### 3. Ensure upstream dependencies are cloned\n\n- `cargo check` requires the `./rolldown` and `./vite` directories to exist (many workspace path dependencies point to `./rolldown/crates/...`).\n- Locally, clone them using the commit hashes from `packages/tools/.upstream-versions.json`.\n- CI handles this automatically via the `.github/actions/clone` action.\n\n### 4. Verify compilation\n\n- Run `cargo check` to ensure the new vite-task compiles without errors.\n- If there are compilation errors, these are **breaking changes** from vite-task. Fix them in the vite-plus codebase (the consuming side), not in vite-task.\n- Common breaking changes include: renamed functions/methods, changed function signatures, new required fields in structs, removed public APIs.\n\n### 5. Run tests\n\n- Run `cargo test -p vite_command -p vite_error -p vite_install -p vite_js_runtime -p vite_migration -p vite_shared -p vite_static_config -p vite-plus-cli -p vite_global_cli` to run the vite-plus crate tests.\n- Note: Some tests require network access (e.g., `vite_install::package_manager` tests, `vite_global_cli::commands::env` tests). These may fail in sandboxed environments. Verify they also fail on the main branch before dismissing them.\n- Note: `cargo test -p vite_task` will NOT work because vite_task is a git dependency, not a workspace member.\n\n### 6. Update snap tests\n\nvite-task changes often affect CLI output, which means snap tests need updating. Common output changes:\n\n- **Status icons**: e.g., cache hit/miss indicators may change\n- **New CLI options**: e.g., new flags added to `vp run` that show up in help output\n- **Cache behavior messages**: e.g., new summary lines about cache status\n- **Task output formatting**: e.g., step numbering, separator lines\n\nTo update snap tests:\n\n1. Push your changes and let CI run the snap tests.\n2. CI will show the diff in the E2E test logs if snap tests fail.\n3. Extract the diff from CI logs and apply it locally.\n4. Check all three platforms (Linux, Mac, Windows) since they may have slightly different snap test coverage.\n5. Watch for trailing newline issues - ensure snap files end consistently.\n\nSnap test files are at `packages/cli/snap-tests/*/snap.txt` and `packages/cli/snap-tests-global/*/snap.txt`.\n\n### 7. Create the PR\n\n- Commit message: `chore: bump vite-task to <short-hash>`\n- PR title: `chore: bump vite-task to <short-hash>`\n- PR body: Link to vite-task CHANGELOG.md diff between old and new commits:\n  ```\n  https://github.com/voidzero-dev/vite-task/compare/<old-hash>...<new-hash>#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4ed\n  ```\n\n### 8. Verify CI\n\nWait for CI and ensure the `done` check passes. Key checks to monitor:\n\n- **Lint**: Clippy and format checks\n- **Test** (Linux, Mac, Windows): Rust unit tests\n- **CLI E2E test** (Linux, Mac, Windows): Snap tests - most likely to fail on a vite-task bump\n- **Run task**: Task runner integration tests\n- **Cargo Deny**: License/advisory checks (may have pre-existing failures unrelated to bump)\n\nThe only **required** status check for merging is `done`, which aggregates the other checks (excluding Cargo Deny).\n\n## Notes\n\n- Building the full CLI locally (`pnpm bootstrap-cli`) requires the rolldown Node.js package to be built first, which is complex. Prefer relying on CI for snap test generation.\n- `Cargo.lock` is automatically updated by cargo when you change the revision in `Cargo.toml`.\n"
  },
  {
    "path": ".claude/skills/spawn-process/SKILL.md",
    "content": "---\nname: spawn-process\ndescription: Guide for writing subprocess execution code using the vite_command crate\nallowed-tools: Read, Grep, Glob, Edit, Write, Bash\n---\n\n# Add Subprocess Execution Code\n\nWhen writing Rust code that needs to spawn subprocesses (resolve binaries, build commands, execute programs), always use the `vite_command` crate. Never use `which`, `tokio::process::Command::new`, or `std::process::Command::new` directly.\n\n## Available APIs\n\n### `vite_command::resolve_bin(name, path_env, cwd)` — Resolve a binary name to an absolute path\n\nHandles PATHEXT (`.cmd`/`.bat`) on Windows. Pass `None` for `path_env` to search the current process PATH.\n\n```rust\n// Resolve using current PATH\nlet bin = vite_command::resolve_bin(\"node\", None, &cwd)?;\n\n// Resolve using a custom PATH\nlet custom_path = std::ffi::OsString::from(&path_env_str);\nlet bin = vite_command::resolve_bin(\"eslint\", Some(&custom_path), &cwd)?;\n```\n\n### `vite_command::build_command(bin_path, cwd)` — Build a command for a pre-resolved binary\n\nReturns `tokio::process::Command` with cwd, inherited stdio, and `fix_stdio_streams` on Unix already configured. Add args, envs, or override stdio as needed.\n\n```rust\nlet bin = vite_command::resolve_bin(\"eslint\", None, &cwd)?;\nlet mut cmd = vite_command::build_command(&bin, &cwd);\ncmd.args(&[\".\", \"--fix\"]);\ncmd.env(\"NODE_ENV\", \"production\");\nlet mut child = cmd.spawn()?;\nlet status = child.wait().await?;\n```\n\n### `vite_command::build_shell_command(shell_cmd, cwd)` — Build a shell command\n\nUses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows. Same stdio and `fix_stdio_streams` setup as `build_command`.\n\n```rust\nlet mut cmd = vite_command::build_shell_command(\"echo hello && ls\", &cwd);\nlet mut child = cmd.spawn()?;\nlet status = child.wait().await?;\n```\n\n### `vite_command::run_command(bin_name, args, envs, cwd)` — Resolve + build + run in one call\n\nCombines resolve_bin, build_command, and status().await. The `envs` HashMap must include `\"PATH\"` if you want custom PATH resolution.\n\n```rust\nlet envs = HashMap::from([(\"PATH\".to_string(), path_value)]);\nlet status = vite_command::run_command(\"node\", &[\"--version\"], &envs, &cwd).await?;\n```\n\n## Dependency Setup\n\nAdd `vite_command` to the crate's `Cargo.toml`:\n\n```toml\n[dependencies]\nvite_command = { workspace = true }\n```\n\nDo NOT add `which` as a direct dependency — binary resolution goes through `vite_command::resolve_bin`.\n\n## Exception\n\n`crates/vite_global_cli/src/shim/exec.rs` uses synchronous `std::process::Command` with Unix `exec()` for process replacement. This is the only place that bypasses `vite_command`.\n"
  },
  {
    "path": ".claude/skills/sync-tsdown-cli/SKILL.md",
    "content": "---\nname: sync-tsdown-cli\ndescription: Compare tsdown CLI options with vp pack and sync any new or removed options. Use when tsdown is upgraded or when you need to check for CLI option drift between tsdown and vp pack.\nallowed-tools: Read, Grep, Glob, Edit, Bash\n---\n\n# Sync tsdown CLI Options with vp pack\n\nCompare the upstream `tsdown` CLI options with `vp pack` (defined in `packages/cli/src/pack-bin.ts`) and sync any differences.\n\n## Steps\n\n1. Run `npx tsdown --help` from `packages/cli/` to get tsdown's current CLI options\n2. Read `packages/cli/src/pack-bin.ts` to see vp pack's current options\n3. Compare and add any new tsdown options to `pack-bin.ts` using the existing cac `.option()` pattern\n4. If tsdown removed options, do NOT remove them from `pack-bin.ts` -- instead add a code comment like `// NOTE: removed from tsdown CLI in vX.Y.Z` above the option so reviewers can decide whether to follow up\n5. Preserve intentional differences:\n   - `-c, --config` is intentionally commented out (vp pack uses vite.config.ts)\n   - `--env-prefix` has a different default (`['VITE_PACK_', 'TSDOWN_']`)\n6. Verify with `pnpm --filter vite-plus build-ts` and `vp pack -h`\n7. If new parameters were added, add a corresponding snap test under `packages/cli/snap-tests/` to verify the new option works correctly\n"
  },
  {
    "path": ".clippy.toml",
    "content": "avoid-breaking-exported-api = false\n\ndisallowed-methods = [\n  { path = \"str::to_ascii_lowercase\", reason = \"To avoid memory allocation, use `cow_utils::CowUtils::cow_to_ascii_lowercase` instead.\" },\n  { path = \"str::to_ascii_uppercase\", reason = \"To avoid memory allocation, use `cow_utils::CowUtils::cow_to_ascii_uppercase` instead.\" },\n  { path = \"str::to_lowercase\", reason = \"To avoid memory allocation, use `cow_utils::CowUtils::cow_to_lowercase` instead.\" },\n  { path = \"str::to_uppercase\", reason = \"To avoid memory allocation, use `cow_utils::CowUtils::cow_to_uppercase` instead.\" },\n  { path = \"str::replace\", reason = \"To avoid memory allocation, use `cow_utils::CowUtils::cow_replace` instead.\" },\n  { path = \"str::replacen\", reason = \"To avoid memory allocation, use `cow_utils::CowUtils::cow_replacen` instead.\" },\n  { path = \"std::env::current_dir\", reason = \"To get an `AbsolutePathBuf`, Use `vite_path::current_dir` instead.\" },\n]\n\ndisallowed-types = [\n  { path = \"std::collections::HashMap\", reason = \"Use `rustc_hash::FxHashMap` instead, which is typically faster.\" },\n  { path = \"std::collections::HashSet\", reason = \"Use `rustc_hash::FxHashSet` instead, which is typically faster.\" },\n  { path = \"std::path::Path\", reason = \"Use `vite_path::RelativePath` or `vite_path::AbsolutePath` instead\" },\n  { path = \"std::path::PathBuf\", reason = \"Use `vite_path::RelativePathBuf` or `vite_path::AbsolutePathBuf` instead\" },\n  { path = \"std::string::String\", reason = \"Use `vite_str::Str` for small strings. For large strings, prefer `Box/Rc/Arc<str>` if mutation is not needed.\" },\n]\n\ndisallowed-macros = [\n  { path = \"std::format\", reason = \"Use `vite_str::format` for small strings.\" },\n  { path = \"std::println\", reason = \"Use `vite_shared::output` functions (`info`, `note`, `success`) instead.\" },\n  { path = \"std::print\", reason = \"Use `vite_shared::output` functions (`info`, `note`, `success`) instead.\" },\n  { path = \"std::eprintln\", reason = \"Use `vite_shared::output` functions (`warn`, `error`) instead.\" },\n  { path = \"std::eprint\", reason = \"Use `vite_shared::output` functions (`warn`, `error`) instead.\" },\n]\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n// README at: https://github.com/devcontainers/templates/tree/main/src/rust\n{\n  \"name\": \"Rust\",\n  // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n  \"image\": \"mcr.microsoft.com/vscode/devcontainers/base:ubuntu-22.04\",\n  \"updateContentCommand\": {\n    \"rustToolchain\": \"rustup show\"\n  },\n  \"containerEnv\": {\n    \"CARGO_TARGET_DIR\": \"/tmp/target\"\n  },\n  \"features\": {\n    \"ghcr.io/devcontainers/features/rust:1\": {},\n    \"ghcr.io/devcontainers-extra/features/fish-apt-get:1\": {}\n  },\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"rust-lang.rust-analyzer\", \"tamasfe.even-better-toml\", \"fill-labs.dependi\"],\n      \"settings\": {\n        \"terminal.integrated.defaultProfile.linux\": \"fish\",\n        \"terminal.integrated.profiles.linux\": {\n          \"fish\": {\n            \"path\": \"/usr/bin/fish\"\n          }\n        }\n      }\n    }\n  },\n  \"postCreateCommand\": \"curl -fsSL https://vite.plus | bash\"\n  // Use 'mounts' to make the cargo cache persistent in a Docker Volume.\n  // \"mounts\": [\n  // \t{\n  // \t\t\"source\": \"devcontainer-cargo-cache-${devcontainerId}\",\n  // \t\t\"target\": \"/usr/local/cargo\",\n  // \t\t\"type\": \"volume\"\n  // \t}\n  // ]\n  // Features to add to the dev container. More info: https://containers.dev/features.\n  // \"features\": {},\n  // Use 'forwardPorts' to make a list of ports inside the container available locally.\n  // \"forwardPorts\": [],\n  // Use 'postCreateCommand' to run commands after the container is created.\n  // \"postCreateCommand\": \"rustc --version\",\n  // Configure tool-specific properties.\n  // \"customizations\": {},\n  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.\n  // \"remoteUser\": \"root\"\n}\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: \"\\U0001F41E Bug report\"\ndescription: Report an issue with Vite+\nlabels: [pending triage]\ntype: Bug\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this bug report.\n\n        Please only file issues here if the bug is in Vite+ itself.\n        If the bug belongs to an underlying tool, report it in that project's tracker (linked in the \"Create new issue\" page).\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: Describe the bug\n      description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description.\n      placeholder: I am doing ... What I expect is ... What is actually happening is ...\n    validations:\n      required: true\n  - type: input\n    id: reproduction\n    attributes:\n      label: Reproduction\n      description: Please provide a link to a minimal reproduction repository or a runnable stackblitz/sandbox. If a report is vague and has no reproduction, it may be closed.\n      placeholder: Reproduction URL\n    validations:\n      required: true\n  - type: textarea\n    id: reproduction-steps\n    attributes:\n      label: Steps to reproduce\n      description: Provide any required steps, commands, or setup details.\n      placeholder: Run `pnpm install` followed by `vp dev`\n  - type: textarea\n    id: system-info\n    attributes:\n      label: System Info\n      description: |\n        Paste the full output of both commands:\n        - `vp env current`\n        - `vp --version`\n      render: shell\n      placeholder: Paste `vp env current` and `vp --version` output here\n    validations:\n      required: true\n  - type: dropdown\n    id: package-manager\n    attributes:\n      label: Used Package Manager\n      description: Select the package manager used in your project\n      options:\n        - npm\n        - yarn\n        - pnpm\n        - bun\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: Logs\n      description: |\n        Optional when a reproduction is provided. Please copy-paste text logs instead of screenshots.\n\n        If relevant, run your command with `--debug` and include the output.\n      render: shell\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please confirm the following\n      options:\n        - label: Read the [Contributing Guidelines](https://github.com/voidzero-dev/vite-plus/blob/main/CONTRIBUTING.md).\n          required: true\n        - label: Check that there isn't [already an issue](https://github.com/voidzero-dev/vite-plus/issues) for the same bug.\n          required: true\n        - label: Confirm this is a Vite+ issue and not an upstream issue (Vite, Vitest, tsdown, Rolldown, or Oxc).\n          required: true\n        - label: The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example).\n          required: true\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Vite Issues\n    url: https://github.com/vitejs/vite/issues/new/choose\n    about: Report issues specific to Vite core in the Vite repository.\n  - name: Vitest Issues\n    url: https://github.com/vitest-dev/vitest/issues/new/choose\n    about: Report issues specific to Vitest in the Vitest repository.\n  - name: tsdown Issues\n    url: https://github.com/rolldown/tsdown/issues/new/choose\n    about: Report issues specific to tsdown in the tsdown repository.\n  - name: Rolldown Issues\n    url: https://github.com/rolldown/rolldown/issues/new/choose\n    about: Report issues specific to Rolldown in the Rolldown repository.\n  - name: Oxc (Oxlint/Oxfmt) Issues\n    url: https://github.com/oxc-project/oxc/issues/new/choose\n    about: Report Oxlint/Oxfmt issues in the Oxc repository.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/docs.yml",
    "content": "name: \"\\U0001F4DA Documentation\"\ndescription: Suggest a documentation improvement for Vite+\nlabels: [documentation]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to fill out this issue.\n  - type: checkboxes\n    id: documentation_is\n    attributes:\n      label: Documentation is\n      options:\n        - label: Missing\n        - label: Outdated\n        - label: Confusing\n        - label: Not sure\n  - type: textarea\n    id: description\n    attributes:\n      label: Explain in Detail\n      description: A clear and concise description of your suggestion. If you intend to submit a PR for this issue, mention it here.\n      placeholder: The description of ... is not clear. I thought it meant ... but it wasn't.\n    validations:\n      required: true\n  - type: textarea\n    id: suggestion\n    attributes:\n      label: Your Suggestion for Changes\n    validations:\n      required: true\n  - type: input\n    id: reference\n    attributes:\n      label: Relevant Page\n      description: Link to the relevant doc page, section, or file.\n      placeholder: https://github.com/voidzero-dev/vite-plus/blob/main/docs/...\n  - type: input\n    id: reproduction\n    attributes:\n      label: Reproduction (Optional)\n      description: If the docs issue is tied to behavior, share a minimal reproduction link.\n      placeholder: Reproduction URL\n  - type: textarea\n    id: reproduction-steps\n    attributes:\n      label: Steps to reproduce (Optional)\n      description: Add steps if the docs issue is about incorrect behavior guidance.\n      placeholder: Run `pnpm install` followed by `vp dev`\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: \"\\U0001F680 New feature proposal\"\ndescription: Propose a new feature to be added to Vite+\nlabels: [pending triage]\ntype: Feature\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for your interest in Vite+ and taking the time to fill out this feature report.\n\n        Please only open feature proposals here for Vite+ itself.\n        If the proposal belongs to an underlying tool, use that project's tracker (linked in the \"Create new issue\" page).\n  - type: textarea\n    id: feature-description\n    attributes:\n      label: Description\n      description: 'Clear and concise description of the problem. Explain use cases and motivation. If you intend to submit a PR for this issue, mention it here.'\n      placeholder: As a developer using Vite+ I want [goal / wish] so that [benefit].\n    validations:\n      required: true\n  - type: textarea\n    id: suggested-solution\n    attributes:\n      label: Suggested solution\n      description: 'Describe a possible API, behavior, or implementation direction.'\n    validations:\n      required: true\n  - type: textarea\n    id: alternative\n    attributes:\n      label: Alternative\n      description: Describe any alternative solutions or features you've considered.\n  - type: textarea\n    id: additional-context\n    attributes:\n      label: Additional context\n      description: Any other context, links, or screenshots about the feature request.\n  - type: checkboxes\n    id: checkboxes\n    attributes:\n      label: Validations\n      description: Before submitting the issue, please confirm the following\n      options:\n        - label: Read the [Contributing Guidelines](https://github.com/voidzero-dev/vite-plus/blob/main/CONTRIBUTING.md).\n          required: true\n        - label: Confirm this request is for Vite+ itself and not for Vite, Vitest, tsdown, Rolldown, or Oxc.\n          required: true\n        - label: Check that there isn't already an issue requesting the same feature.\n          required: true\n"
  },
  {
    "path": ".github/actions/build-upstream/action.yml",
    "content": "name: 'Build with Upstream Repositories'\ndescription: 'Builds Vite+ with the upstream repositories'\ninputs:\n  target:\n    description: 'The target platform'\n    required: true\n  print-after-build:\n    description: 'Print the output after the build'\n    required: false\n    default: 'false'\n\nruns:\n  using: 'composite'\n  steps:\n    - uses: ./.github/actions/download-rolldown-binaries\n      with:\n        github-token: ${{ github.token }}\n        target: ${{ inputs.target }}\n        upload: 'false'\n\n    # Compute cache key once before any builds modify files\n    # (packages/cli/package.json is modified by syncTestPackageExports during build-ts)\n    # Include env vars (RELEASE_BUILD, DEBUG, VERSION) to ensure cache miss on release builds\n    - name: Compute NAPI binding cache key\n      id: cache-key\n      shell: bash\n      run: |\n        echo \"key=napi-binding-v3-${{ inputs.target }}-${{ env.RELEASE_BUILD }}-${{ env.DEBUG }}-${{ env.VERSION }}-${{ env.NPM_TAG }}-${{ hashFiles('packages/tools/.upstream-versions.json', 'Cargo.lock', 'crates/**/*.rs', 'crates/*/Cargo.toml', 'packages/cli/binding/**/*.rs', 'packages/cli/binding/Cargo.toml', 'Cargo.toml', '.cargo/config.toml', 'packages/cli/package.json', 'packages/cli/build.ts') }}\" >> $GITHUB_OUTPUT\n\n    # Cache NAPI bindings and Rust CLI binary (the slow parts, especially on Windows)\n    - name: Restore NAPI binding cache\n      id: cache-restore\n      uses: actions/cache/restore@94b89442628ad1d101e352b7ee38f30e1bef108e # v5\n      with:\n        path: |\n          packages/cli/binding/*.node\n          packages/cli/binding/index.js\n          packages/cli/binding/index.d.ts\n          packages/cli/binding/index.cjs\n          packages/cli/binding/index.d.cts\n          target/${{ inputs.target }}/release/vp\n          target/${{ inputs.target }}/release/vp.exe\n          target/${{ inputs.target }}/release/vp-shim.exe\n        key: ${{ steps.cache-key.outputs.key }}\n\n    # Apply Vite+ branding patches to vite source (CI checks out\n    # upstream vite which doesn't have branding patches)\n    - name: Brand vite\n      shell: bash\n      run: pnpm exec tool brand-vite\n\n    # Build upstream TypeScript packages first (don't depend on native bindings)\n    - name: Build upstream TypeScript packages\n      shell: bash\n      run: |\n        pnpm --filter @rolldown/pluginutils build\n        pnpm --filter rolldown build-node\n        pnpm --filter vite build-types\n        pnpm --filter \"@voidzero-dev/*\" build\n        pnpm --filter vite-plus build-ts\n\n    # NAPI builds - only run on cache miss (slow, especially on Windows)\n    # Must run before vite-plus TypeScript builds which depend on the bindings\n    - name: Build NAPI bindings (x86_64-linux)\n      shell: bash\n      if: steps.cache-restore.outputs.cache-hit != 'true' && inputs.target == 'x86_64-unknown-linux-gnu'\n      run: |\n        pnpm --filter=vite-plus build-native --target ${{ inputs.target }} --use-napi-cross\n      env:\n        TARGET_CC: clang\n        DEBUG: napi:*\n\n    - name: Build NAPI bindings (aarch64-linux)\n      shell: bash\n      if: steps.cache-restore.outputs.cache-hit != 'true' && inputs.target == 'aarch64-unknown-linux-gnu'\n      run: |\n        pnpm --filter=vite-plus build-native --target ${{ inputs.target }} --use-napi-cross\n      env:\n        TARGET_CC: clang\n        TARGET_CFLAGS: '-D_BSD_SOURCE'\n        DEBUG: napi:*\n\n    - name: Build NAPI bindings (non-Linux targets)\n      shell: bash\n      if: steps.cache-restore.outputs.cache-hit != 'true' && !contains(inputs.target, 'linux')\n      run: |\n        pnpm --filter=vite-plus build-native --target ${{ inputs.target }}\n      env:\n        DEBUG: napi:*\n\n    - name: Build Rust CLI binary (x86_64-linux)\n      if: steps.cache-restore.outputs.cache-hit != 'true' && inputs.target == 'x86_64-unknown-linux-gnu'\n      shell: bash\n      run: |\n        pnpm exec napi build --use-napi-cross --target ${{ inputs.target }} --release -p vite_global_cli\n      env:\n        TARGET_CC: clang\n        DEBUG: napi:*\n\n    - name: Build Rust CLI binary (aarch64-linux)\n      if: steps.cache-restore.outputs.cache-hit != 'true' && inputs.target == 'aarch64-unknown-linux-gnu'\n      shell: bash\n      run: |\n        pnpm exec napi build --use-napi-cross --target ${{ inputs.target }} --release -p vite_global_cli\n      env:\n        TARGET_CC: clang\n        TARGET_CFLAGS: '-D_BSD_SOURCE'\n        DEBUG: napi:*\n\n    - name: Build Rust CLI binary (non-Linux targets)\n      if: steps.cache-restore.outputs.cache-hit != 'true' && !contains(inputs.target, 'linux')\n      shell: bash\n      run: cargo build --release --target ${{ inputs.target }} -p vite_global_cli\n\n    - name: Build trampoline shim binary (Windows only)\n      if: steps.cache-restore.outputs.cache-hit != 'true' && contains(inputs.target, 'windows')\n      shell: bash\n      run: cargo build --release --target ${{ inputs.target }} -p vite_trampoline\n\n    - name: Save NAPI binding cache\n      if: steps.cache-restore.outputs.cache-hit != 'true'\n      uses: actions/cache/save@94b89442628ad1d101e352b7ee38f30e1bef108e # v5\n      with:\n        path: |\n          packages/cli/binding/*.node\n          packages/cli/binding/index.js\n          packages/cli/binding/index.d.ts\n          packages/cli/binding/index.cjs\n          packages/cli/binding/index.d.cts\n          target/${{ inputs.target }}/release/vp\n          target/${{ inputs.target }}/release/vp.exe\n          target/${{ inputs.target }}/release/vp-shim.exe\n        key: ${{ steps.cache-key.outputs.key }}\n\n    # Build vite-plus TypeScript after native bindings are ready\n    - name: Build vite-plus TypeScript packages\n      shell: bash\n      run: |\n        pnpm --filter=vite-plus build-ts\n\n    - name: Print output after build\n      shell: bash\n      if: inputs.print-after-build == 'true'\n      run: |\n        pnpm vp -h\n        pnpm vp run -h\n        pnpm vp lint -h\n        pnpm vp test -h\n        pnpm vp build -h\n        pnpm vp fmt -h\n"
  },
  {
    "path": ".github/actions/clone/action.yml",
    "content": "name: 'Clone Repositories'\ndescription: 'Clone self and upstream repositories'\n\ninputs:\n  ecosystem-ci-project:\n    description: 'The ecosystem ci project to clone'\n    required: false\n    default: ''\n\noutputs:\n  ecosystem-ci-project-path:\n    description: 'The path where the ecosystem ci project was cloned'\n    value: ${{ steps.ecosystem-ci-project-hash.outputs.ECOSYSTEM_CI_PROJECT_PATH }}\n\nruns:\n  using: 'composite'\n  steps:\n    - name: Output rolldown and vite hash\n      shell: bash\n      id: upstream-versions\n      run: |\n        node -e \"console.log('ROLLDOWN_HASH=' + require('./packages/tools/.upstream-versions.json').rolldown.hash)\" >> $GITHUB_OUTPUT\n        node -e \"console.log('ROLLDOWN_VITE_HASH=' + require('./packages/tools/.upstream-versions.json')['vite'].hash)\" >> $GITHUB_OUTPUT\n\n    - name: Output ecosystem ci project hash\n      shell: bash\n      id: ecosystem-ci-project-hash\n      if: ${{ inputs.ecosystem-ci-project != '' }}\n      run: |\n        node -e \"console.log('ECOSYSTEM_CI_PROJECT_HASH=' + require('./ecosystem-ci/repo.json')['${{ inputs.ecosystem-ci-project }}'].hash)\" >> $GITHUB_OUTPUT\n        node -e \"console.log('ECOSYSTEM_CI_PROJECT_REPOSITORY=' + require('./ecosystem-ci/repo.json')['${{ inputs.ecosystem-ci-project }}'].repository.replace('https://github.com/', '').replace('.git', ''))\" >> $GITHUB_OUTPUT\n        echo \"ECOSYSTEM_CI_PROJECT_PATH=${{ runner.temp }}/vite-plus-ecosystem-ci/${{ inputs.ecosystem-ci-project }}\" >> $GITHUB_OUTPUT\n\n    - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      with:\n        repository: rolldown/rolldown\n        path: rolldown\n        ref: ${{ steps.upstream-versions.outputs.ROLLDOWN_HASH }}\n\n    - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      with:\n        repository: vitejs/vite\n        path: vite\n        ref: ${{ steps.upstream-versions.outputs.ROLLDOWN_VITE_HASH }}\n\n    # Disable autocrlf to preserve LF line endings on Windows\n    # This prevents prettier/eslint from failing with \"Delete ␍\" errors\n    - name: Configure git for LF line endings\n      if: ${{ inputs.ecosystem-ci-project != '' }}\n      shell: bash\n      run: git config --global core.autocrlf false\n\n    - name: Clone ecosystem ci project\n      if: ${{ inputs.ecosystem-ci-project != '' }}\n      shell: bash\n      run: npx tsx ecosystem-ci/clone.ts ${{ inputs.ecosystem-ci-project }}\n"
  },
  {
    "path": ".github/actions/download-rolldown-binaries/action.yml",
    "content": "name: 'Download Rolldown Binaries'\ndescription: 'Download previous release rolldown binaries and upload as artifact'\n\ninputs:\n  github-token:\n    description: 'GitHub token for accessing GitHub Package Registry'\n    required: true\n  target:\n    description: 'The target platform'\n    default: 'x86_64-unknown-linux-gnu'\n    required: false\n  upload:\n    description: 'Upload the rolldown binaries as artifact'\n    required: false\n    default: 'true'\n\nruns:\n  using: 'composite'\n  steps:\n    - name: Install previous release\n      shell: bash\n      run: |\n        if ${{ runner.os == 'Windows' }}; then\n          export TARGET=\"win32-x64-msvc\"\n        elif ${{ runner.os == 'Linux' }}; then\n          export TARGET=\"linux-x64-gnu\"\n        elif ${{ runner.os == 'macOS' }}; then\n          export TARGET=\"darwin-arm64\"\n        fi\n\n        # Pin to the version from checked-out rolldown source to avoid mismatch\n        # between JS code (built from source) and native binary (downloaded from npm).\n        # Falls back to npm latest only when rolldown source isn't cloned yet\n        # (e.g., the standalone download-previous-rolldown-binaries job).\n        if [ -f \"./rolldown/packages/rolldown/package.json\" ]; then\n          export VERSION=$(node -p \"require('./rolldown/packages/rolldown/package.json').version\")\n          echo \"Using rolldown version from source: ${VERSION}\"\n        else\n          export VERSION=$(npm view --json rolldown | jq -r '.version')\n          echo \"Warning: rolldown source not found, using npm latest: ${VERSION}\"\n        fi\n        npm pack \"@rolldown/binding-${TARGET}@${VERSION}\"\n        tar -xzf \"rolldown-binding-${TARGET}-${VERSION}.tgz\"\n        if [ -d \"./rolldown/packages/rolldown/src\" ]; then\n          cp \"./package/rolldown-binding.${TARGET}.node\" ./rolldown/packages/rolldown/src\n          ls ./rolldown/packages/rolldown/src\n        fi\n      env:\n        GITHUB_TOKEN: ${{ inputs.github-token }}\n    - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n      if: ${{ inputs.upload == 'true' }}\n      with:\n        name: rolldown-binaries\n        path: ./package/rolldown-binding.*.node\n        if-no-files-found: error\n\n    - name: Clean up\n      shell: bash\n      run: |\n        rm -rf package\n        rm *.tgz\n"
  },
  {
    "path": ".github/actions/set-snapshot-version/action.yml",
    "content": "name: Compute Release Version\ndescription: Get latest tag from GitHub and increment the patch version\n\ninputs:\n  npm_tag:\n    description: 'npm tag (latest or alpha)'\n    required: true\n    default: 'latest'\n\noutputs:\n  version:\n    description: The computed version string\n    value: ${{ steps.version.outputs.version }}\n\nruns:\n  using: composite\n  steps:\n    - name: Compute next patch version\n      id: version\n      shell: bash\n      run: |\n        git fetch --tags --quiet\n        npm install --prefix ${{ github.action_path }} semver > /dev/null 2>&1\n        VERSION_OUTPUT=$(node ${{ github.action_path }}/compute-version.mjs \"${{ inputs.npm_tag }}\")\n        echo \"$VERSION_OUTPUT\"\n        echo \"$VERSION_OUTPUT\" | tail -n 1 >> $GITHUB_OUTPUT\n"
  },
  {
    "path": ".github/actions/set-snapshot-version/compute-version.mjs",
    "content": "import { execSync } from 'node:child_process';\n\nimport semver from 'semver';\n\nconst npmTag = process.argv[2] || 'latest';\n\n// Get all version tags\nconst tagsOutput = execSync('git tag -l \"v*\"', { encoding: 'utf-8' }).trim();\nconst tags = tagsOutput ? tagsOutput.split('\\n') : [];\n\n// Parse and filter to valid semver, then find latest stable (no prerelease)\nconst stableTags = tags\n  .map((tag) => semver.parse(tag.replace(/^v/, '')))\n  .filter((v) => v !== null && v.prerelease.length === 0);\n\nlet nextVersion;\nif (stableTags.length === 0) {\n  nextVersion = '0.1.0';\n} else {\n  stableTags.sort(semver.rcompare);\n  const latest = stableTags[0];\n  nextVersion = semver.inc(latest, 'patch');\n}\n\nlet version;\nif (npmTag === 'alpha') {\n  // Find existing alpha tags for this version\n  const alphaPrefix = `v${nextVersion}-alpha.`;\n  const alphaTags = tags\n    .filter((tag) => tag.startsWith(alphaPrefix))\n    .map((tag) => semver.parse(tag.replace(/^v/, '')))\n    .filter((v) => v !== null);\n\n  let alphaNum = 0;\n  if (alphaTags.length > 0) {\n    alphaTags.sort(semver.rcompare);\n    alphaNum = alphaTags[0].prerelease[1] + 1;\n  }\n  version = `${nextVersion}-alpha.${alphaNum}`;\n} else {\n  version = nextVersion;\n}\n\nconst latestStable = stableTags.length > 0 ? `v${stableTags[0].version}` : 'none';\nconsole.log(`Computed version: ${version} (latest stable tag: ${latestStable})`);\nconsole.log(`version=${version}`);\n"
  },
  {
    "path": ".github/actions/set-snapshot-version/package.json",
    "content": "{\n  \"private\": true,\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": ".github/renovate.json",
    "content": "{\n  \"$schema\": \"https://docs.renovatebot.com/renovate-schema.json\",\n  \"extends\": [\"github>Boshen/renovate\"],\n  \"ignorePaths\": [\n    \"packages/cli/snap-tests/**\",\n    \"packages/cli/snap-tests-global/**\",\n    \"packages/cli/snap-tests-todo/**\",\n    \"bench/fixtures/**\",\n    \"rolldown/**\",\n    \"vite/**\"\n  ],\n  \"packageRules\": [\n    {\n      \"matchPackageNames\": [\"vitest-dev\"],\n      \"enabled\": false\n    },\n    {\n      \"matchPackageNames\": [\n        \"fspy\",\n        \"vite_glob\",\n        \"vite_path\",\n        \"vite_str\",\n        \"vite_task\",\n        \"vite_workspace\",\n        \"https://github.com/voidzero-dev/vite-task\"\n      ],\n      \"enabled\": false\n    }\n  ]\n}\n"
  },
  {
    "path": ".github/scripts/upgrade-deps.mjs",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nconst ROOT = process.cwd();\n\n// ============ GitHub API ============\nasync function getLatestTagCommit(owner, repo) {\n  const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/tags`, {\n    headers: {\n      Authorization: `token ${process.env.GITHUB_TOKEN}`,\n      Accept: 'application/vnd.github.v3+json',\n    },\n  });\n  if (!res.ok) {\n    throw new Error(`Failed to fetch tags for ${owner}/${repo}: ${res.status} ${res.statusText}`);\n  }\n  const tags = await res.json();\n  if (!Array.isArray(tags) || !tags.length) {\n    throw new Error(`No tags found for ${owner}/${repo}`);\n  }\n  if (!tags[0]?.commit?.sha) {\n    throw new Error(`Invalid tag structure for ${owner}/${repo}: missing commit SHA`);\n  }\n  console.log(`${repo} -> ${tags[0].name}`);\n  return tags[0].commit.sha;\n}\n\n// ============ npm Registry ============\nasync function getLatestNpmVersion(packageName) {\n  const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);\n  if (!res.ok) {\n    throw new Error(\n      `Failed to fetch npm version for ${packageName}: ${res.status} ${res.statusText}`,\n    );\n  }\n  const data = await res.json();\n  if (!data?.version) {\n    throw new Error(`Invalid npm response for ${packageName}: missing version field`);\n  }\n  return data.version;\n}\n\n// ============ Update .upstream-versions.json ============\nasync function updateUpstreamVersions() {\n  const filePath = path.join(ROOT, 'packages/tools/.upstream-versions.json');\n  const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n\n  // rolldown -> rolldown/rolldown\n  data.rolldown.hash = await getLatestTagCommit('rolldown', 'rolldown');\n\n  // vite -> vitejs/vite\n  data['vite'].hash = await getLatestTagCommit('vitejs', 'vite');\n\n  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\\n');\n  console.log('Updated .upstream-versions.json');\n}\n\n// ============ Update pnpm-workspace.yaml ============\nasync function updatePnpmWorkspace(versions) {\n  const filePath = path.join(ROOT, 'pnpm-workspace.yaml');\n  let content = fs.readFileSync(filePath, 'utf8');\n\n  // Update vitest-dev override (handle pre-release versions like -beta.1, -rc.0)\n  // Handle both quoted ('npm:vitest@^...') and unquoted (npm:vitest@^...) forms\n  content = content.replace(\n    /vitest-dev: '?npm:vitest@\\^[\\d.]+(-[\\w.]+)?'?/,\n    `vitest-dev: 'npm:vitest@^${versions.vitest}'`,\n  );\n\n  // Update tsdown in catalog (handle pre-release versions)\n  content = content.replace(/tsdown: \\^[\\d.]+(-[\\w.]+)?/, `tsdown: ^${versions.tsdown}`);\n\n  // Update @oxc-node/cli in catalog\n  content = content.replace(\n    /'@oxc-node\\/cli': \\^[\\d.]+(-[\\w.]+)?/,\n    `'@oxc-node/cli': ^${versions.oxcNodeCli}`,\n  );\n\n  // Update @oxc-node/core in catalog\n  content = content.replace(\n    /'@oxc-node\\/core': \\^[\\d.]+(-[\\w.]+)?/,\n    `'@oxc-node/core': ^${versions.oxcNodeCore}`,\n  );\n\n  // Update oxfmt in catalog\n  content = content.replace(/oxfmt: =[\\d.]+(-[\\w.]+)?/, `oxfmt: =${versions.oxfmt}`);\n\n  // Update oxlint in catalog (but not oxlint-tsgolint)\n  content = content.replace(/oxlint: =[\\d.]+(-[\\w.]+)?\\n/, `oxlint: =${versions.oxlint}\\n`);\n\n  // Update oxlint-tsgolint in catalog\n  content = content.replace(\n    /oxlint-tsgolint: =[\\d.]+(-[\\w.]+)?/,\n    `oxlint-tsgolint: =${versions.oxlintTsgolint}`,\n  );\n\n  fs.writeFileSync(filePath, content);\n  console.log('Updated pnpm-workspace.yaml');\n}\n\n// ============ Update packages/test/package.json ============\nasync function updateTestPackage(vitestVersion) {\n  const filePath = path.join(ROOT, 'packages/test/package.json');\n  const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n\n  // Update all @vitest/* devDependencies\n  for (const dep of Object.keys(pkg.devDependencies)) {\n    if (dep.startsWith('@vitest/')) {\n      pkg.devDependencies[dep] = vitestVersion;\n    }\n  }\n\n  // Update vitest-dev devDependency\n  if (pkg.devDependencies['vitest-dev']) {\n    pkg.devDependencies['vitest-dev'] = `^${vitestVersion}`;\n  }\n\n  // Update @vitest/ui peerDependency if present\n  if (pkg.peerDependencies?.['@vitest/ui']) {\n    pkg.peerDependencies['@vitest/ui'] = vitestVersion;\n  }\n\n  fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\\n');\n  console.log('Updated packages/test/package.json');\n}\n\n// ============ Update packages/core/package.json ============\nasync function updateCorePackage(devtoolsVersion) {\n  const filePath = path.join(ROOT, 'packages/core/package.json');\n  const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'));\n\n  // Update @vitejs/devtools in devDependencies\n  if (pkg.devDependencies?.['@vitejs/devtools']) {\n    pkg.devDependencies['@vitejs/devtools'] = `^${devtoolsVersion}`;\n  }\n\n  fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\\n');\n  console.log('Updated packages/core/package.json');\n}\n\nconsole.log('Fetching latest versions…');\n\nconst [\n  vitestVersion,\n  tsdownVersion,\n  devtoolsVersion,\n  oxcNodeCliVersion,\n  oxcNodeCoreVersion,\n  oxfmtVersion,\n  oxlintVersion,\n  oxlintTsgolintVersion,\n] = await Promise.all([\n  getLatestNpmVersion('vitest'),\n  getLatestNpmVersion('tsdown'),\n  getLatestNpmVersion('@vitejs/devtools'),\n  getLatestNpmVersion('@oxc-node/cli'),\n  getLatestNpmVersion('@oxc-node/core'),\n  getLatestNpmVersion('oxfmt'),\n  getLatestNpmVersion('oxlint'),\n  getLatestNpmVersion('oxlint-tsgolint'),\n]);\n\nconsole.log(`vitest: ${vitestVersion}`);\nconsole.log(`tsdown: ${tsdownVersion}`);\nconsole.log(`@vitejs/devtools: ${devtoolsVersion}`);\nconsole.log(`@oxc-node/cli: ${oxcNodeCliVersion}`);\nconsole.log(`@oxc-node/core: ${oxcNodeCoreVersion}`);\nconsole.log(`oxfmt: ${oxfmtVersion}`);\nconsole.log(`oxlint: ${oxlintVersion}`);\nconsole.log(`oxlint-tsgolint: ${oxlintTsgolintVersion}`);\n\nawait updateUpstreamVersions();\nawait updatePnpmWorkspace({\n  vitest: vitestVersion,\n  tsdown: tsdownVersion,\n  oxcNodeCli: oxcNodeCliVersion,\n  oxcNodeCore: oxcNodeCoreVersion,\n  oxfmt: oxfmtVersion,\n  oxlint: oxlintVersion,\n  oxlintTsgolint: oxlintTsgolintVersion,\n});\nawait updateTestPackage(vitestVersion);\nawait updateCorePackage(devtoolsVersion);\n\nconsole.log('Done!');\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\npermissions:\n  # Doing it explicitly because the default permission only includes metadata: read.\n  contents: read\n\non:\n  workflow_dispatch:\n  pull_request:\n    types: [opened, synchronize, labeled]\n  push:\n    branches:\n      - main\n    paths-ignore:\n      - '**/*.md'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}\n  cancel-in-progress: ${{ github.ref_name != 'main' }}\n\ndefaults:\n  run:\n    shell: bash\n\njobs:\n  optimize-ci:\n    runs-on: ubuntu-latest # or whichever runner you use for your CI\n    outputs:\n      skip: ${{ steps.check_skip.outputs.skip }}\n    steps:\n      - name: Optimize CI\n        id: check_skip\n        uses: withgraphite/graphite-ci-action@ee395f3a78254c006d11339669c6cabddf196f72\n        with:\n          graphite_token: ${{ secrets.GRAPHITE_CI_OPTIMIZER_TOKEN }}\n\n  detect-changes:\n    runs-on: ubuntu-latest\n    needs: optimize-ci\n    if: needs.optimize-ci.outputs.skip == 'false'\n    permissions:\n      contents: read\n      pull-requests: read\n    outputs:\n      code-changed: ${{ steps.filter.outputs.code }}\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2\n        id: filter\n        with:\n          filters: |\n            code:\n              - '!**/*.md'\n\n  download-previous-rolldown-binaries:\n    needs: detect-changes\n    if: needs.detect-changes.outputs.code-changed == 'true'\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/download-rolldown-binaries\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n  test:\n    needs: detect-changes\n    if: needs.detect-changes.outputs.code-changed == 'true'\n    name: Test\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: namespace-profile-linux-x64-default\n            target: x86_64-unknown-linux-gnu\n          - os: windows-latest\n            target: x86_64-pc-windows-msvc\n          - os: namespace-profile-mac-default\n            target: aarch64-apple-darwin\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - name: Setup Dev Drive\n        if: runner.os == 'Windows'\n        uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0\n        with:\n          drive-size: 12GB\n          drive-format: ReFS\n          env-mapping: |\n            CARGO_HOME,{{ DEV_DRIVE }}/.cargo\n            RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: test\n          target-dir: ${{ runner.os == 'Windows' && format('{0}/target', env.DEV_DRIVE) || '' }}\n\n      - run: rustup target add x86_64-unknown-linux-musl\n        if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}\n\n      - run: cargo check --all-targets --all-features\n        env:\n          RUSTFLAGS: '-D warnings --cfg tokio_unstable' # also update .cargo/config.toml\n\n      # Test all crates/* packages. New crates are automatically included.\n      # Also test vite-plus-cli (lives outside crates/) to catch type sync issues.\n      - run: cargo test $(for d in crates/*/; do echo -n \"-p $(basename $d) \"; done) -p vite-plus-cli\n        env:\n          RUST_MIN_STACK: 8388608\n\n  lint:\n    needs: detect-changes\n    if: needs.detect-changes.outputs.code-changed == 'true'\n    name: Lint\n    runs-on: namespace-profile-linux-x64-default\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: lint\n          tools: cargo-shear\n          components: clippy rust-docs rustfmt\n\n      - run: |\n          cargo shear\n          cargo fmt --check\n          # cargo clippy --all-targets --all-features -- -D warnings\n          # RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items\n\n      - uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0\n        with:\n          files: .\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - name: Install docs dependencies\n        run: pnpm -C docs install --frozen-lockfile\n\n      - name: Deduplicate dependencies\n        run: pnpm dedupe --check\n\n  run:\n    name: Run task\n    runs-on: namespace-profile-linux-x64-default\n    needs:\n      - download-previous-rolldown-binaries\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: run\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          name: rolldown-binaries\n          path: ./rolldown/packages/rolldown/src\n          merge-multiple: true\n\n      - name: Build with upstream\n        uses: ./.github/actions/build-upstream\n        with:\n          target: x86_64-unknown-linux-gnu\n\n      - name: Install Global CLI vp\n        run: |\n          pnpm bootstrap-cli:ci\n          echo \"$HOME/.vite-plus/bin\" >> $GITHUB_PATH\n\n      - name: Print help for built-in commands\n        run: |\n          which vp\n          vp -h\n          vp run -h\n          vp lint -h\n          vp test -h\n          vp build -h\n          vp fmt -h\n\n  cli-e2e-test:\n    name: CLI E2E test\n    needs:\n      - download-previous-rolldown-binaries\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: namespace-profile-linux-x64-default\n          - os: namespace-profile-mac-default\n          - os: windows-latest\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - name: Setup Dev Drive\n        if: runner.os == 'Windows'\n        uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0\n        with:\n          drive-size: 12GB\n          drive-format: ReFS\n          env-mapping: |\n            CARGO_HOME,{{ DEV_DRIVE }}/.cargo\n            RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: cli-e2e-test\n          target-dir: ${{ runner.os == 'Windows' && format('{0}/target', env.DEV_DRIVE) || '' }}\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - name: Install docs dependencies\n        run: pnpm -C docs install --frozen-lockfile\n\n      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          name: rolldown-binaries\n          path: ./rolldown/packages/rolldown/src\n          merge-multiple: true\n\n      - name: Build with upstream\n        uses: ./.github/actions/build-upstream\n        with:\n          target: ${{ matrix.os == 'namespace-profile-linux-x64-default' && 'x86_64-unknown-linux-gnu' ||  matrix.os == 'windows-latest' && 'x86_64-pc-windows-msvc' || 'aarch64-apple-darwin' }}\n\n      - name: Check TypeScript types\n        if: ${{ matrix.os == 'namespace-profile-linux-x64-default' }}\n        run: pnpm tsgo\n\n      - name: Install Global CLI vp\n        run: |\n          pnpm bootstrap-cli:ci\n          if [[ \"$RUNNER_OS\" == \"Windows\" ]]; then\n            echo \"$USERPROFILE\\.vite-plus\\bin\" >> $GITHUB_PATH\n          else\n            echo \"$HOME/.vite-plus/bin\" >> $GITHUB_PATH\n          fi\n\n      - name: Verify vp installation\n        run: |\n          which vp\n          vp --version\n          vp -h\n\n      - name: Run vp check\n        run: vp check\n\n      - name: Test global package install (powershell)\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: pwsh\n        run: |\n          echo \"PATH: $env:Path\"\n          where.exe node\n          where.exe npm\n          where.exe npx\n          where.exe vp\n          vp env doctor\n\n          # Test 1: Install a JS-based CLI (typescript)\n          vp install -g typescript\n          tsc --version\n          where.exe tsc\n\n          # Test 2: Verify the package was installed correctly\n          Get-ChildItem \"$env:USERPROFILE\\.vite-plus\\packages\\typescript\\\"\n          Get-ChildItem \"$env:USERPROFILE\\.vite-plus\\bin\\\"\n\n          # Test 3: Uninstall\n          vp uninstall -g typescript\n\n          # Test 4: Verify uninstall removed shim\n          Write-Host \"Checking bin dir after uninstall:\"\n          Get-ChildItem \"$env:USERPROFILE\\.vite-plus\\bin\\\"\n          $shimPath = \"$env:USERPROFILE\\.vite-plus\\bin\\tsc.cmd\"\n          if (Test-Path $shimPath) {\n            Write-Error \"tsc shim file still exists at $shimPath\"\n            exit 1\n          }\n          Write-Host \"tsc shim removed successfully\"\n\n          # Test 5: use session\n          vp env use 18\n          node --version\n          vp env doctor\n          vp env use --unset\n          node --version\n\n      - name: Test global package install (cmd)\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: cmd\n        run: |\n          echo \"PATH: %PATH%\"\n          where.exe node\n          where.exe npm\n          where.exe npx\n          where.exe vp\n\n          vp env use 18\n          node --version\n          vp env use --unset\n          node --version\n\n          vp env doctor\n\n          REM Test 1: Install a JS-based CLI (typescript)\n          vp install -g typescript\n          tsc --version\n          where.exe tsc\n\n          REM Test 2: Verify the package was installed correctly\n          dir \"%USERPROFILE%\\.vite-plus\\packages\\typescript\\\"\n          dir \"%USERPROFILE%\\.vite-plus\\bin\\\"\n\n          REM Test 3: Uninstall\n          vp uninstall -g typescript\n\n          REM Test 4: Verify uninstall removed shim (.cmd wrapper)\n          echo Checking bin dir after uninstall:\n          dir \"%USERPROFILE%\\.vite-plus\\bin\\\"\n          if exist \"%USERPROFILE%\\.vite-plus\\bin\\tsc.cmd\" (\n            echo Error: tsc.cmd shim file still exists\n            exit /b 1\n          )\n          echo tsc.cmd shim removed successfully\n\n          REM Test 5: Verify shell script was also removed (for Git Bash)\n          if exist \"%USERPROFILE%\\.vite-plus\\bin\\tsc\" (\n            echo Error: tsc shell script still exists\n            exit /b 1\n          )\n          echo tsc shell script removed successfully\n\n          REM Test 6: use session\n          vp env use 18\n          node --version\n          vp env doctor\n          vp env use --unset\n          node --version\n\n      - name: Test global package install (bash)\n        run: |\n          echo \"PATH: $PATH\"\n          ls -la ~/.vite-plus/\n          ls -la ~/.vite-plus/bin/\n          which node\n          which npm\n          which npx\n          which vp\n          vp env doctor\n\n          # Test 1: Install a JS-based CLI (typescript)\n          vp install -g typescript\n          tsc --version\n          which tsc\n\n          # Test 2: Verify the package was installed correctly\n          ls -la ~/.vite-plus/packages/typescript/\n          ls -la ~/.vite-plus/bin/\n\n          # Test 3: Uninstall\n          vp uninstall -g typescript\n\n          # Test 4: Verify uninstall removed shim\n          echo \"Checking bin dir after uninstall:\"\n          ls -la ~/.vite-plus/bin/\n          if [ -f ~/.vite-plus/bin/tsc ]; then\n            echo \"Error: tsc shim file still exists at ~/.vite-plus/bin/tsc\"\n            exit 1\n          fi\n          echo \"tsc shim removed successfully\"\n\n          # Test 5: use session\n          vp env use 18\n          node --version\n          vp env doctor\n          vp env use --unset\n          node --version\n\n      - name: Install Playwright browsers\n        run: pnpx playwright install chromium\n\n      - name: Run CLI snapshot tests\n        run: |\n          RUST_BACKTRACE=1 pnpm test\n          if ! git diff --exit-code; then\n            echo \"::error::Snapshot diff detected. Run 'pnpm -F vite-plus snap-test' locally and commit the updated snap.txt files.\"\n            git diff --stat\n            git diff\n            exit 1\n          fi\n        env:\n          RUST_MIN_STACK: 8388608\n\n      # Upgrade tests (merged from separate job to avoid duplicate build)\n      - name: Test upgrade (bash)\n        shell: bash\n        run: |\n          # Helper to read the installed CLI version from package.json\n          get_cli_version() {\n            node -p \"require(require('path').resolve(process.env.USERPROFILE || process.env.HOME, '.vite-plus', 'current', 'node_modules', 'vite-plus', 'package.json')).version\"\n          }\n\n          # Save initial (dev build) version\n          INITIAL_VERSION=$(get_cli_version)\n          echo \"Initial version: $INITIAL_VERSION\"\n\n          # --check queries npm registry and prints update status\n          vp upgrade --check\n\n          # full upgrade: download, extract, swap\n          vp upgrade --force\n          vp --version\n          vp env doctor\n\n          ls -la ~/.vite-plus/\n\n          # Verify version changed after update\n          UPDATED_VERSION=$(get_cli_version)\n          echo \"Updated version: $UPDATED_VERSION\"\n          if [ \"$UPDATED_VERSION\" == \"$INITIAL_VERSION\" ]; then\n            echo \"Error: version should have changed after upgrade (still $INITIAL_VERSION)\"\n            exit 1\n          fi\n\n          # rollback to the previous version\n          vp upgrade --rollback\n          vp --version\n          vp env doctor\n\n          # Verify version restored after rollback\n          ROLLBACK_VERSION=$(get_cli_version)\n          echo \"Rollback version: $ROLLBACK_VERSION\"\n          if [ \"$ROLLBACK_VERSION\" != \"$INITIAL_VERSION\" ]; then\n            echo \"Error: version should have been restored after rollback (expected $INITIAL_VERSION, got $ROLLBACK_VERSION)\"\n            exit 1\n          fi\n\n      - name: Test upgrade (powershell)\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: pwsh\n        run: |\n          Get-ChildItem \"$env:USERPROFILE\\.vite-plus\\\"\n\n          # Helper to read the installed CLI version from package.json\n          function Get-CliVersion {\n            node -p \"require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'node_modules', 'vite-plus', 'package.json')).version\"\n          }\n\n          # Save initial (dev build) version\n          $initialVersion = Get-CliVersion\n          Write-Host \"Initial version: $initialVersion\"\n\n          # --check queries npm registry and prints update status\n          vp upgrade --check\n\n          # full upgrade: download, extract, swap\n          vp upgrade --force\n          vp --version\n          vp env doctor\n\n          Get-ChildItem \"$env:USERPROFILE\\.vite-plus\\\"\n\n          # Verify version changed after update\n          $updatedVersion = Get-CliVersion\n          Write-Host \"Updated version: $updatedVersion\"\n          if ($updatedVersion -eq $initialVersion) {\n            Write-Error \"Error: version should have changed after upgrade (still $initialVersion)\"\n            exit 1\n          }\n\n          # rollback to the previous version\n          vp upgrade --rollback\n          vp --version\n          vp env doctor\n\n          # Verify version restored after rollback\n          $rollbackVersion = Get-CliVersion\n          Write-Host \"Rollback version: $rollbackVersion\"\n          if ($rollbackVersion -ne $initialVersion) {\n            Write-Error \"Error: version should have been restored after rollback (expected $initialVersion, got $rollbackVersion)\"\n            exit 1\n          }\n\n      - name: Test upgrade (cmd)\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: cmd\n        run: |\n          REM Save initial (dev build) version\n          for /f \"usebackq delims=\" %%v in (`node -p \"require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'node_modules', 'vite-plus', 'package.json')).version\"`) do set INITIAL_VERSION=%%v\n          echo Initial version: %INITIAL_VERSION%\n\n          REM --check queries npm registry and prints update status\n          vp upgrade --check\n\n          REM full upgrade: download, extract, swap\n          vp upgrade --force\n          vp --version\n          vp env doctor\n\n          dir \"%USERPROFILE%\\.vite-plus\\\"\n\n          REM Verify version changed after update\n          for /f \"usebackq delims=\" %%v in (`node -p \"require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'node_modules', 'vite-plus', 'package.json')).version\"`) do set UPDATED_VERSION=%%v\n          echo Updated version: %UPDATED_VERSION%\n          if \"%UPDATED_VERSION%\"==\"%INITIAL_VERSION%\" (\n            echo Error: version should have changed after upgrade, still %INITIAL_VERSION%\n            exit /b 1\n          )\n\n          REM rollback to the previous version\n          vp upgrade --rollback\n          vp --version\n          vp env doctor\n\n          REM Verify version restored after rollback\n          for /f \"usebackq delims=\" %%v in (`node -p \"require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'node_modules', 'vite-plus', 'package.json')).version\"`) do set ROLLBACK_VERSION=%%v\n          echo Rollback version: %ROLLBACK_VERSION%\n          if not \"%ROLLBACK_VERSION%\"==\"%INITIAL_VERSION%\" (\n            echo Error: version should have been restored after rollback, expected %INITIAL_VERSION%, got %ROLLBACK_VERSION%\n            exit /b 1\n          )\n\n      - name: Test implode (bash)\n        shell: bash\n        run: |\n          vp implode --yes\n          ls -la ~/\n          VP_HOME=\"${USERPROFILE:-$HOME}/.vite-plus\"\n          if [ -d \"$VP_HOME\" ]; then\n            echo \"Error: $VP_HOME still exists after implode\"\n            exit 1\n          fi\n          # Reinstall\n          pnpm bootstrap-cli:ci\n          vp --version\n\n      - name: Test implode (powershell)\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: pwsh\n        run: |\n          vp implode --yes\n          Start-Sleep -Seconds 5\n          dir \"$env:USERPROFILE\\\"\n          if (Test-Path \"$env:USERPROFILE\\.vite-plus\") {\n            Write-Error \"~/.vite-plus still exists after implode\"\n            exit 1\n          }\n          pnpm bootstrap-cli:ci\n          vp --version\n\n      - name: Test implode (cmd)\n        if: ${{ matrix.os == 'windows-latest' }}\n        shell: cmd\n        run: |\n          REM vp.exe renames its own parent directory; cmd.exe may report\n          REM \"The system cannot find the path specified\" on exit — ignore it.\n          vp implode --yes || ver >NUL\n          timeout /T 5 /NOBREAK >NUL\n          dir \"%USERPROFILE%\\\"\n          if exist \"%USERPROFILE%\\.vite-plus\" (\n            echo Error: .vite-plus still exists after implode\n            exit /b 1\n          )\n          pnpm bootstrap-cli:ci\n          vp --version\n\n  install-e2e-test:\n    name: Local CLI `vp install` E2E test\n    needs:\n      - download-previous-rolldown-binaries\n    runs-on: namespace-profile-linux-x64-default\n    # Run if: not a PR, OR PR has 'test: install-e2e' label\n    if: >-\n      github.event_name != 'pull_request' ||\n      contains(github.event.pull_request.labels.*.name, 'test: install-e2e')\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: install-e2e-test\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          name: rolldown-binaries\n          path: ./rolldown/packages/rolldown/src\n          merge-multiple: true\n\n      - name: Build with upstream\n        uses: ./.github/actions/build-upstream\n        with:\n          target: x86_64-unknown-linux-gnu\n\n      - name: Build CLI\n        run: |\n          pnpm bootstrap-cli:ci\n          echo \"$HOME/.vite-plus/bin\" >> $GITHUB_PATH\n\n      - name: Run local CLI `vp install`\n        run: |\n          export PATH=$PWD/node_modules/.bin:$PATH\n          vp -h\n          # Test vp install on various repositories with different package managers\n          repos=(\n            # pnpm workspace\n            \"pnpm/pnpm:pnpm\"\n            \"vitejs/vite:vite\"\n            # yarn workspace\n            \"napi-rs/napi-rs:napi-rs\"\n            \"toeverything/AFFiNE:AFFiNE\"\n            # npm workspace\n            \"npm/cli:npm\"\n            \"redhat-developer/vscode-extension-tester:vscode-extension-tester\"\n          )\n\n          for repo_info in \"${repos[@]}\"; do\n            IFS=':' read -r repo dir_name <<< \"$repo_info\"\n            echo \"Testing vp install on $repo…\"\n            # remove the directory if it exists\n            if [ -d \"$RUNNER_TEMP/$dir_name\" ]; then\n              rm -rf \"$RUNNER_TEMP/$dir_name\"\n            fi\n            git clone --depth 1 \"https://github.com/$repo.git\" \"$RUNNER_TEMP/$dir_name\"\n            cd \"$RUNNER_TEMP/$dir_name\"\n            vp install\n            # run again to show install cache increase by time\n            time vp install\n            echo \"✓ Successfully installed dependencies for $repo\"\n            echo \"\"\n          done\n\n  done:\n    runs-on: ubuntu-latest\n    if: always()\n    needs:\n      - test\n      - lint\n      - run\n      - cli-e2e-test\n    steps:\n      - run: exit 1\n        # Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379\n        if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}\n"
  },
  {
    "path": ".github/workflows/claude.yml",
    "content": "name: Claude Code\n\non:\n  issues:\n    types: [assigned]\n\njobs:\n  analyze:\n    if: github.repository == 'voidzero-dev/vite-plus' && github.event.action == 'assigned' && github.event.assignee.login == 'boshen'\n    runs-on: ubuntu-slim\n    permissions:\n      contents: read\n      issues: write\n      id-token: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n        with:\n          fetch-depth: 100\n          persist-credentials: true\n\n      - name: Run Claude Code\n        id: claude\n        uses: anthropics/claude-code-action@26ec041249acb0a944c0a47b6c0c13f05dbc5b44 # v1.0.70\n        with:\n          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}\n          assignee_trigger: 'boshen'\n          claude_args: --allowedTools \"Edit,Write,Read,Glob,Grep,Bash(gh:*),Bash(cargo:*),Bash(git:*),Bash(just:*),WebFetch,TodoWrite\"\n          prompt: |\n            Analyze issue #${{ github.event.issue.number }} in ${{ github.repository }} and determine if it can be fixed.\n\n            First, use `gh issue view ${{ github.event.issue.number }}` to read the issue details.\n\n            Then:\n            1. Search the codebase to gather relevant context (related files, existing implementations, tests)\n            2. Determine if the issue is fixable and estimate the complexity\n\n            Finally, post a comment on the issue with:\n            - A brief summary of your understanding of the issue\n            - Relevant files/code you found\n            - Whether this issue is fixable (yes/no/needs clarification)\n            - If the issue is unclear, ask for more context\n            - If fixable, provide a concrete implementation plan with specific steps\n            - Any potential concerns or blockers\n\n      - name: Unassign boshen\n        if: always()\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: gh issue edit ${{ github.event.issue.number }} --remove-assignee Boshen\n"
  },
  {
    "path": ".github/workflows/cleanup-cache.yml",
    "content": "name: Cleanup github runner caches on closed pull requests\non:\n  pull_request:\n    types:\n      - closed\n\njobs:\n  cleanup:\n    runs-on: ubuntu-latest\n    permissions:\n      actions: write\n    steps:\n      - name: Cleanup\n        run: |\n          echo \"Fetching list of cache keys\"\n          cacheKeysForPR=$(gh cache list --ref $BRANCH --limit 100 --json id --jq '.[].id')\n\n          ## Setting this to not fail the workflow while deleting cache keys.\n          set +e\n          echo \"Deleting caches…\"\n          for cacheKey in $cacheKeysForPR\n          do\n              gh cache delete $cacheKey\n          done\n          echo \"Done\"\n        env:\n          GH_TOKEN: ${{ github.token }}\n          GH_REPO: ${{ github.repository }}\n          BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge\n"
  },
  {
    "path": ".github/workflows/deny.yml",
    "content": "name: Cargo Deny\n\npermissions: {}\n\non:\n  workflow_dispatch:\n  pull_request:\n    types: [opened, synchronize]\n    paths:\n      - 'Cargo.lock'\n      - 'deny.toml'\n      - '.github/workflows/deny.yml'\n  push:\n    branches:\n      - main\n    paths:\n      - 'Cargo.lock'\n      - 'deny.toml'\n      - '.github/workflows/deny.yml'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}\n  cancel-in-progress: ${{ github.ref_name != 'main' }}\n\njobs:\n  deny:\n    name: Cargo Deny\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n        with:\n          persist-credentials: false\n\n      - name: Output rolldown hash\n        id: upstream-versions\n        run: node -e \"console.log('ROLLDOWN_HASH=' + require('./packages/tools/.upstream-versions.json').rolldown.hash)\" >> $GITHUB_OUTPUT\n\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n        with:\n          repository: rolldown/rolldown\n          path: rolldown\n          ref: ${{ steps.upstream-versions.outputs.ROLLDOWN_HASH }}\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          restore-cache: false\n          # Pinned to 0.18.6+ for CVSS 4.0 support (EmbarkStudios/cargo-deny#805)\n          tools: cargo-deny@0.19.0\n\n      - run: cargo deny check\n"
  },
  {
    "path": ".github/workflows/e2e-test.yml",
    "content": "name: E2E Test\n\npermissions: {}\n\non:\n  workflow_dispatch:\n  schedule:\n    # Run every day at 0:00 GMT (8:00 AM Singapore time)\n    - cron: '0 0 * * *'\n  push:\n    branches:\n      - main\n    paths-ignore:\n      - '**/*.md'\n  pull_request:\n    types: [opened, synchronize, labeled]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}\n  cancel-in-progress: ${{ github.ref_name != 'main' }}\n\ndefaults:\n  run:\n    shell: bash\n\njobs:\n  detect-changes:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      pull-requests: read\n    outputs:\n      related-files-changed: ${{ steps.filter.outputs.related-files }}\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2\n        id: filter\n        with:\n          filters: |\n            related-files:\n              - 'packages/**/build.ts'\n              - .github/workflows/e2e-test.yml\n              - 'ecosystem-ci/*'\n\n  download-previous-rolldown-binaries:\n    needs: detect-changes\n    runs-on: ubuntu-latest\n    # Run if: not a PR, OR PR has 'test: e2e' label, OR PR is from deps/upstream-update branch, OR build.ts files changed\n    if: >-\n      github.event_name != 'pull_request' ||\n      contains(github.event.pull_request.labels.*.name, 'test: e2e') ||\n      github.head_ref == 'deps/upstream-update' ||\n      needs.detect-changes.outputs.related-files-changed == 'true'\n    permissions:\n      contents: read\n      packages: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/download-rolldown-binaries\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n\n  build:\n    name: Build vite-plus packages (${{ matrix.os }})\n    runs-on: ${{ matrix.os }}\n    permissions:\n      contents: read\n      packages: read\n    needs:\n      - download-previous-rolldown-binaries\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-latest\n            target: x86_64-unknown-linux-gnu\n          - os: windows-latest\n            target: x86_64-pc-windows-msvc\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      # Disable Windows Defender real-time scanning to speed up I/O-heavy builds (~30-50% faster)\n      - name: Disable Windows Defender\n        if: runner.os == 'Windows'\n        shell: powershell\n        run: Set-MpPreference -DisableRealtimeMonitoring $true\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: e2e-build-${{ matrix.os }}\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          name: rolldown-binaries\n          path: ./rolldown/packages/rolldown/src\n          merge-multiple: true\n\n      - name: Build with upstream\n        uses: ./.github/actions/build-upstream\n        with:\n          target: ${{ matrix.target }}\n\n      - name: Pack packages into tgz\n        run: |\n          mkdir -p tmp/tgz\n          cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../..\n          cd packages/test && pnpm pack --pack-destination ../../tmp/tgz && cd ../..\n          cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../..\n          # Copy vp binary for e2e-test job (findVpBinary expects it in target/)\n          cp target/${{ matrix.target }}/release/vp tmp/tgz/vp 2>/dev/null || cp target/${{ matrix.target }}/release/vp.exe tmp/tgz/vp.exe 2>/dev/null || true\n          cp target/${{ matrix.target }}/release/vp-shim.exe tmp/tgz/vp-shim.exe 2>/dev/null || true\n          ls -la tmp/tgz\n\n      - name: Upload tgz artifacts\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n        with:\n          name: vite-plus-packages-${{ matrix.os }}\n          path: tmp/tgz/\n          retention-days: 1\n\n  e2e-test:\n    name: ${{ matrix.project.name }} E2E test (${{ matrix.os }})\n    env:\n      # For packing manager install from github package registry\n      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    permissions:\n      contents: read\n      packages: read\n    needs:\n      - build\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 10\n    strategy:\n      fail-fast: false\n      matrix:\n        os:\n          - ubuntu-latest\n          - windows-latest\n        project:\n          - name: vibe-dashboard\n            node-version: 24\n            command: |\n              npx playwright install chromium\n              # FIXME: Failed to load JS plugin: ./plugins/debugger.js\n              # vp run ready\n              vp fmt\n              vp test\n              vp run build\n          # FIXME: TypeError: Failed to fetch dynamically imported module\n          # - name: skeleton\n          #   node-version: 24\n          #   command: |\n          #     vp run format\n          #     vp run lint:check\n          #     vp run check\n          #     npx playwright install chromium\n          #     vp run test\n          - name: rollipop\n            node-version: 22\n            command: |\n              vp run -r build\n              # FIXME: typescript-eslint(no-redundant-type-constituents): 'rolldownExperimental.DevEngine' is an 'error' type that acts as 'any' and overrides all other types in this union type.\n              vp run lint || true\n              # FIXME: src/bundler-pool.ts(8,8): error TS2307: Cannot find module '@rollipop/core' or its corresponding type declarations.\n              vp run -r typecheck || true\n              vp run format\n              vp run @rollipop/common#test\n              vp run @rollipop/core#test\n              vp run @rollipop/dev-server#test\n          - name: frm-stack\n            node-version: 24\n            command: |\n              vp run lint:check\n              vp run format:check\n              vp run typecheck\n              vp run @yourcompany/api#test\n              vp run @yourcompany/backend-core#test\n          - name: vue-mini\n            node-version: 24\n            command: |\n              # FIXME: skip format for now, will re-enable after prettier migration support\n              # vp run format\n              vp run lint\n              vp run type\n              vp run test -- --coverage\n          # SKIP: vite-plugin-react - vite-task config loading incompatibility\n          # vite-task needs to load vite.config.js for all workspace packages to build the task graph,\n          # but the vite-plus process starts with workspace root as cwd.\n          # The plugin-react-swc playgrounds use SWC plugins (e.g., @swc/plugin-emotion) which\n          # cannot be resolved when loading the config from workspace root.\n          #\n          # Minimal reproduction:\n          #   git clone https://github.com/vitejs/vite-plugin-react /tmp/vite-plugin-react-test\n          #   cd /tmp/vite-plugin-react-test && pnpm install && pnpm run build\n          #   node packages/plugin-react-swc/playground/emotion-plugin/vite.config.js\n          #   # Error: Cannot find module '@swc/plugin-emotion'\n          #\n          # This works when running from within the playground directory (pnpm run build)\n          # because pnpm's symlink structure allows resolution, but fails when loading from workspace root.\n          # - name: vite-plugin-react\n          #   node-version: 22\n          #   command: |\n          #     vp run format\n          #     vp run lint -- --fix\n          #     # TODO(fengmk2): run all builds and tests after tsdown version upgrade\n          #     vp run @vitejs/plugin-rsc#build\n          #     vp run @vitejs/plugin-rsc#test\n          - name: vitepress\n            node-version: 24\n            command: |\n              npx playwright install chromium\n              vp run format\n              vp run build\n              vp test run -r __tests__/unit\n              vp run tests-e2e#test\n              VITE_TEST_BUILD=1 vp run tests-e2e#test\n              vp run tests-init#test\n          - name: tanstack-start-helloworld\n            node-version: 24\n            command: |\n              npx playwright install chromium\n              vp run test\n              vp run build\n          - name: oxlint-plugin-complexity\n            node-version: 22\n            command: |\n              vp run format\n              vp run format:check\n              vp run build\n              vp run lint\n              vp run test:run\n              npx tsc --noEmit\n          - name: vite-vue-vercel\n            node-version: 24\n            command: |\n              npx playwright install chromium\n              vp run test\n              vp run build\n          - name: dify\n            node-version: 24\n            directory: web\n            command: |\n              vp run type-check:tsgo\n              vp run build\n              vp run test navigation-utils.test.ts real-browser-flicker.test.tsx workflow-parallel-limit.test.tsx\n          - name: viteplus-ws-repro\n            node-version: 24\n            command: |\n              vp test run\n          - name: vp-config\n            node-version: 22\n            command: |\n              vp check\n              vp pack\n              vp test\n          - name: vinext\n            node-version: 24\n            command: |\n              vp run build\n              vp check --fix\n              vp run check\n              vp run test\n          - name: reactive-resume\n            node-version: 24\n            command: |\n              vp fmt\n              vp lint --type-aware\n              vp build\n              vp test\n          - name: yaak\n            node-version: 24\n            command: |\n              vp fmt --ignore-path .oxfmtignore\n              # FIXME: type-aware lint fails with \"Invalid tsconfig\" without full Rust/wasm bootstrap\n              vp lint || true\n              vp test\n          - name: npmx.dev\n            node-version: 24\n            command: |\n              vp fmt\n              vp run lint\n              vp run test:types\n              vp test --project unit\n          - name: vite-plus-jest-dom-repro\n            node-version: 24\n            command: |\n              vp test run\n        exclude:\n          # frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows\n          - os: windows-latest\n            project:\n              name: frm-stack\n          # dify only runs on Linux for now\n          - os: windows-latest\n            project:\n              name: dify\n          # vinext uses workerd native deps that don't build on Windows\n          - os: windows-latest\n            project:\n              name: vinext\n          # yaak is a Tauri app with Rust/wasm deps\n          - os: windows-latest\n            project:\n              name: yaak\n          # npmx.dev is a Nuxt app, ubuntu-only for now\n          - os: windows-latest\n            project:\n              name: npmx.dev\n\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n        with:\n          ecosystem-ci-project: ${{ matrix.project.name }}\n\n      # Disable Windows Defender real-time scanning to speed up I/O-heavy operations\n      - name: Disable Windows Defender\n        if: runner.os == 'Windows'\n        shell: powershell\n        run: Set-MpPreference -DisableRealtimeMonitoring $true\n\n      - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5\n        with:\n          node-version: ${{ matrix.project.node-version }}\n          package-manager-cache: false\n\n      - name: Download vite-plus packages\n        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          name: vite-plus-packages-${{ matrix.os }}\n          path: tmp/tgz\n\n      - name: Install vp CLI\n        shell: bash\n        run: |\n          # Place vp binary where install-global-cli.ts expects it (target/release/)\n          mkdir -p target/release\n          cp tmp/tgz/vp target/release/vp 2>/dev/null || cp tmp/tgz/vp.exe target/release/vp.exe 2>/dev/null || true\n          cp tmp/tgz/vp-shim.exe target/release/vp-shim.exe 2>/dev/null || true\n          chmod +x target/release/vp 2>/dev/null || true\n          node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-0.0.0.tgz\n          # Use USERPROFILE (native Windows path) instead of HOME (Git Bash path /c/Users/...)\n          # so cmd.exe and Node.js execSync can resolve binaries in PATH\n          echo \"${USERPROFILE:-$HOME}/.vite-plus/bin\" >> $GITHUB_PATH\n\n      - name: Migrate in ${{ matrix.project.name }}\n        working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }}\n        shell: bash\n        run: |\n          node $GITHUB_WORKSPACE/ecosystem-ci/patch-project.ts ${{ matrix.project.name }}\n          vp install --no-frozen-lockfile\n\n      - name: Verify local tgz packages installed\n        working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }}\n        shell: bash\n        run: node $GITHUB_WORKSPACE/ecosystem-ci/verify-install.ts\n\n      - name: Run vite-plus commands in ${{ matrix.project.name }}\n        working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }}\n        run: ${{ matrix.project.command }}\n\n  notify-failure:\n    name: Notify on failure\n    runs-on: ubuntu-latest\n    needs: e2e-test\n    if: ${{ failure() && github.event_name == 'schedule' }}\n    permissions:\n      contents: read\n      issues: write\n    steps:\n      - name: Create or update GitHub issue on failure\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          GH_REPO: ${{ github.repository }}\n        run: |\n          ISSUE_TITLE=\"E2E Test Scheduled Run Failed\"\n          ISSUE_LABEL=\"e2e-failure\"\n          RUN_URL=\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"\n\n          # Create label if it doesn't exist\n          if ! gh label list --json name --jq '.[].name' | grep -q \"^${ISSUE_LABEL}$\"; then\n            CREATE_LABEL_OUTPUT=$(gh label create \"$ISSUE_LABEL\" --color \"d73a4a\" --description \"E2E test scheduled run failure\" 2>&1)\n            if [ $? -eq 0 ]; then\n              echo \"Created label: $ISSUE_LABEL\"\n            elif echo \"$CREATE_LABEL_OUTPUT\" | grep -qi \"already exists\"; then\n              echo \"Label '$ISSUE_LABEL' already exists, continuing.\"\n            else\n              echo \"Error: Failed to create label '$ISSUE_LABEL':\"\n              echo \"$CREATE_LABEL_OUTPUT\" >&2\n              exit 1\n            fi\n          fi\n\n          # Search for existing open issue with the label\n          EXISTING_ISSUE=$(gh issue list --label \"$ISSUE_LABEL\" --state open --json number --jq '.[0].number')\n\n          if [ -z \"$EXISTING_ISSUE\" ]; then\n            # Create new issue if none exists\n            gh issue create \\\n              --title \"$ISSUE_TITLE\" \\\n              --label \"$ISSUE_LABEL\" \\\n              --body \"The scheduled E2E test run has failed.\n\n          **Failed Run:** $RUN_URL\n          **Time:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')\n\n          Please investigate the failure and fix any issues.\"\n            echo \"Created new issue\"\n          else\n            # Add comment to existing issue\n            gh issue comment \"$EXISTING_ISSUE\" \\\n              --body \"The scheduled E2E test run has failed again.\n\n          **Failed Run:** $RUN_URL\n          **Time:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')\"\n            echo \"Added comment to issue #$EXISTING_ISSUE\"\n          fi\n"
  },
  {
    "path": ".github/workflows/issue-close-require.yml",
    "content": "name: Issue Close Require\n\non:\n  schedule:\n    - cron: '0 0 * * *'\n\njobs:\n  close-issues:\n    if: github.repository == 'voidzero-dev/vite-plus'\n    runs-on: ubuntu-slim\n    permissions:\n      issues: write # for actions-cool/issues-helper to update issues\n      pull-requests: write # for actions-cool/issues-helper to update PRs\n    steps:\n      - name: needs reproduction\n        uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3\n        with:\n          actions: 'close-issues'\n          token: ${{ secrets.GITHUB_TOKEN }}\n          labels: 'needs reproduction'\n          inactive-day: 3\n"
  },
  {
    "path": ".github/workflows/issue-labeled.yml",
    "content": "name: Issue Labeled\n\non:\n  issues:\n    types: [labeled]\n\njobs:\n  reply-labeled:\n    if: github.repository == 'voidzero-dev/vite-plus'\n    runs-on: ubuntu-slim\n    permissions:\n      issues: write # for actions-cool/issues-helper to update issues\n      pull-requests: write # for actions-cool/issues-helper to update PRs\n    steps:\n      - name: contribution welcome\n        if: github.event.label.name == 'contribution welcome' || github.event.label.name == 'help wanted'\n        uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3\n        with:\n          actions: 'remove-labels'\n          token: ${{ secrets.GITHUB_TOKEN }}\n          issue-number: ${{ github.event.issue.number }}\n          labels: 'pending triage, needs reproduction'\n\n      - name: needs reproduction\n        if: github.event.label.name == 'needs reproduction'\n        uses: actions-cool/issues-helper@71b62d7da76e59ff7b193904feb6e77d4dbb2777 # v3\n        with:\n          actions: 'create-comment, remove-labels'\n          token: ${{ secrets.GITHUB_TOKEN }}\n          issue-number: ${{ github.event.issue.number }}\n          labels: 'pending triage'\n          body: |\n            Hello @${{ github.event.issue.user.login }} 👋\n\n            Please provide a [minimal reproduction](https://stackoverflow.com/help/minimal-reproducible-example) using a GitHub repository. This helps us understand and resolve your issue much faster.\n\n            **A good reproduction should be:**\n            - **Minimal** – include only the code necessary to demonstrate the issue\n            - **Complete** – contain everything needed to run and observe the problem\n            - **Reproducible** – consistently show the issue with clear steps\n\n            If no reproduction is provided, issues labeled `needs reproduction` will be closed after 3 days of inactivity.\n\n            For more context on why this is required, please read: https://antfu.me/posts/why-reproductions-are-required\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  workflow_dispatch:\n    inputs:\n      npm_tag:\n        description: 'npm tag for publish'\n        required: true\n        default: 'latest'\n        type: choice\n        options:\n          - latest\n          - alpha\n      version:\n        description: 'Override version (leave empty to auto-compute). Use when retrying a failed publish.'\n        required: false\n        default: ''\n        type: string\n\npermissions: {}\n\nenv:\n  RELEASE_BUILD: 'true'\n  DEBUG: 'napi:*'\n  NPM_TAG: ${{ inputs.npm_tag }}\n\njobs:\n  prepare:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    outputs:\n      version: ${{ steps.version.outputs.version }}\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n        with:\n          fetch-depth: 0\n          fetch-tags: true\n      - uses: ./.github/actions/set-snapshot-version\n        if: ${{ inputs.version == '' }}\n        id: computed\n        with:\n          npm_tag: ${{ inputs.npm_tag }}\n\n      - name: Set final version\n        id: version\n        run: echo \"version=${{ inputs.version || steps.computed.outputs.version }}\" >> $GITHUB_OUTPUT\n\n  build-rust:\n    runs-on: ${{ matrix.settings.os }}\n    needs: prepare\n    permissions:\n      contents: read\n    env:\n      VERSION: ${{ needs.prepare.outputs.version }}\n    strategy:\n      fail-fast: false\n      matrix:\n        settings:\n          - target: aarch64-apple-darwin\n            os: macos-latest\n          - target: x86_64-apple-darwin\n            os: macos-latest\n          - target: aarch64-unknown-linux-gnu\n            os: ubuntu-latest\n          - target: x86_64-unknown-linux-gnu\n            os: ubuntu-latest\n          - target: x86_64-pc-windows-msvc\n            os: windows-latest\n          - target: aarch64-pc-windows-msvc\n            os: windows-latest\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.2\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: release\n\n      - name: Rustup Adds Target\n        run: rustup target add ${{ matrix.settings.target }}\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - name: Set binding version\n        shell: bash\n        run: |\n          pnpm exec tool replace-file-content packages/cli/binding/Cargo.toml 'version = \"0.0.0\"' 'version = \"${{ env.VERSION }}\"'\n          pnpm exec tool replace-file-content crates/vite_global_cli/Cargo.toml 'version = \"0.0.0\"' 'version = \"${{ env.VERSION }}\"'\n          cat crates/vite_global_cli/Cargo.toml\n\n      - name: Verify version replacement\n        shell: bash\n        run: |\n          if grep -q 'version = \"0.0.0\"' crates/vite_global_cli/Cargo.toml; then\n            echo \"ERROR: Version replacement failed for crates/vite_global_cli/Cargo.toml\"\n            head -5 crates/vite_global_cli/Cargo.toml\n            exit 1\n          fi\n          if grep -q 'version = \"0.0.0\"' packages/cli/binding/Cargo.toml; then\n            echo \"ERROR: Version replacement failed for packages/cli/binding/Cargo.toml\"\n            head -5 packages/cli/binding/Cargo.toml\n            exit 1\n          fi\n          echo \"Version replacement verified successfully\"\n\n      - name: Build\n        uses: ./.github/actions/build-upstream\n        with:\n          target: ${{ matrix.settings.target }}\n\n      - name: Upload Vite+ native artifact\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n        with:\n          name: vite-plus-native-${{ matrix.settings.target }}\n          path: ./packages/cli/binding/*.node\n          if-no-files-found: error\n\n      - name: Upload Rust CLI binary artifact\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n        with:\n          name: vite-global-cli-${{ matrix.settings.target }}\n          path: |\n            ./target/${{ matrix.settings.target }}/release/vp\n            ./target/${{ matrix.settings.target }}/release/vp.exe\n            ./target/${{ matrix.settings.target }}/release/vp-shim.exe\n          if-no-files-found: error\n\n      - name: Remove .node files before upload dist\n        if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }}\n        run: |\n          rm ./packages/core/dist/**/*.node\n\n      - name: Upload core dist\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n        if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }}\n        with:\n          name: core\n          path: ./packages/core/dist\n          if-no-files-found: error\n\n      - name: Upload cli dist\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n        if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }}\n        with:\n          name: cli\n          path: ./packages/cli/dist\n          if-no-files-found: error\n\n      - name: Upload cli skills (docs for agent integration)\n        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2\n        if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }}\n        with:\n          name: cli-skills\n          path: ./packages/cli/skills\n          if-no-files-found: error\n\n  Release:\n    runs-on: ubuntu-latest\n    needs: [prepare, build-rust]\n    permissions:\n      contents: write\n      packages: write\n      id-token: write # Required for OIDC\n    env:\n      VERSION: ${{ needs.prepare.outputs.version }}\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0\n\n      - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0\n        with:\n          node-version-file: .node-version\n          package-manager-cache: false\n          registry-url: 'https://registry.npmjs.org'\n          cache: 'pnpm'\n\n      - name: Install dependencies\n        run: pnpm install\n\n      - name: Download cli dist\n        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          path: packages/cli/dist\n          pattern: cli\n          merge-multiple: true\n\n      - name: Download cli skills\n        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          path: packages/cli/skills\n          pattern: cli-skills\n          merge-multiple: true\n\n      - name: Download cli binding\n        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          path: packages/cli/artifacts\n          pattern: vite-plus-native-*\n\n      - name: Download core dist\n        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          path: packages/core/dist\n          pattern: core\n          merge-multiple: true\n\n      - uses: ./.github/actions/download-rolldown-binaries\n        with:\n          github-token: ${{ github.token }}\n          target: x86_64-unknown-linux-gnu\n          upload: 'false'\n\n      - name: Download Rust CLI binaries\n        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0\n        with:\n          path: rust-cli-artifacts\n          pattern: vite-global-cli-*\n\n      - name: Move Rust CLI binaries to target directories\n        run: |\n          # Move each artifact's binary to the correct target directory\n          for artifact_dir in rust-cli-artifacts/vite-global-cli-*/; do\n            if [ -d \"$artifact_dir\" ]; then\n              # Extract target name from directory (e.g., vite-global-cli-x86_64-unknown-linux-gnu -> x86_64-unknown-linux-gnu)\n              dir_name=$(basename \"$artifact_dir\")\n              target_name=${dir_name#vite-global-cli-}\n              # Create target directory and copy binary\n              mkdir -p \"target/${target_name}/release\"\n              cp -r \"$artifact_dir\"* \"target/${target_name}/release/\"\n            fi\n          done\n          # Show what we have (fail if no binaries found)\n          vp_files=$(find target -name \"vp*\" -type f 2>/dev/null || echo \"\")\n          if [ -z \"$vp_files\" ]; then\n            echo \"Error: No vp binaries found in target directory\"\n            echo \"Artifact contents:\"\n            find rust-cli-artifacts -type f || true\n            exit 1\n          fi\n          echo \"Found binaries:\"\n          echo \"$vp_files\"\n\n      - name: Set npm packages version\n        run: |\n          sed -i 's/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/' packages/core/package.json\n          sed -i 's/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/' packages/test/package.json\n          sed -i 's/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/' packages/cli/package.json\n\n      - name: Build test\n        run: pnpm --filter=@voidzero-dev/vite-plus-test build\n\n      - name: 'Setup npm'\n        run: |\n          npm install -g npm@latest\n\n      - name: Publish native addons\n        run: |\n          node ./packages/cli/publish-native-addons.ts\n\n      - name: Publish\n        run: |\n          pnpm publish --filter=./packages/core --tag ${{ inputs.npm_tag }} --access public --no-git-checks\n          pnpm publish --filter=./packages/test --tag ${{ inputs.npm_tag }} --access public --no-git-checks\n          pnpm publish --filter=./packages/cli --tag ${{ inputs.npm_tag }} --access public --no-git-checks\n\n      - name: Create release body\n        run: |\n          if [[ \"${{ inputs.npm_tag }}\" == \"latest\" ]]; then\n            INSTALL_BASH=\"curl -fsSL https://vite.plus | bash\"\n            INSTALL_PS1=\"irm https://vite.plus/ps1 | iex\"\n          else\n            INSTALL_BASH=\"curl -fsSL https://vite.plus | VITE_PLUS_VERSION=${{ env.VERSION }} bash\"\n            INSTALL_PS1=\"\\\\\\$env:VITE_PLUS_VERSION=\\\\\\\"${{ env.VERSION }}\\\\\\\"; irm https://vite.plus/ps1 | iex\"\n          fi\n          cat > ./RELEASE_BODY.md <<EOF\n          ## vite-plus v${{ env.VERSION }}\n\n          ### Published Packages\n\n          - \\`@voidzero-dev/vite-plus-core@${{ env.VERSION }}\\`\n          - \\`@voidzero-dev/vite-plus-test@${{ env.VERSION }}\\`\n          - \\`vite-plus@${{ env.VERSION }}\\`\n\n          ### Installation\n\n          **macOS/Linux:**\n          \\`\\`\\`bash\n          ${INSTALL_BASH}\n          \\`\\`\\`\n\n          **Windows:**\n          \\`\\`\\`powershell\n          ${INSTALL_PS1}\n          \\`\\`\\`\n\n          View the full commit: https://github.com/${{ github.repository }}/commit/${{ github.sha }}\n          EOF\n\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0\n        id: release\n        with:\n          body_path: ./RELEASE_BODY.md\n          draft: false\n          make_latest: ${{ inputs.npm_tag == 'latest' }}\n          prerelease: ${{ inputs.npm_tag == 'alpha' }}\n          name: vite-plus v${{ env.VERSION }}\n          tag_name: v${{ env.VERSION }}\n          target_commitish: ${{ github.sha }}\n\n      - name: Send Discord notification\n        if: ${{ inputs.npm_tag == 'latest' }}\n        uses: tsickert/discord-webhook@b217a69502f52803de774ded2b1ab7c282e99645 # v7.0.0\n        with:\n          webhook-url: ${{ secrets.DISCORD_RELEASES_WEBHOOK_URL }}\n          embed-title: vite-plus v${{ env.VERSION }}\n          embed-description: |\n            A new release is available!\n\n            **Published Packages:**\n            • @voidzero-dev/vite-plus-core@${{ env.VERSION }}\n            • @voidzero-dev/vite-plus-test@${{ env.VERSION }}\n            • vite-plus@${{ env.VERSION }}\n\n            **Install:**\n            • macOS/Linux: `curl -fsSL https://vite.plus | bash`\n            • Windows: `irm https://vite.plus/ps1 | iex`\n          embed-url: https://github.com/${{ github.repository }}/releases/tag/v${{ env.VERSION }}\n"
  },
  {
    "path": ".github/workflows/test-standalone-install.yml",
    "content": "name: Test Standalone Install Scripts\n\npermissions: {}\n\non:\n  workflow_dispatch:\n  pull_request:\n    paths:\n      - 'packages/cli/install.sh'\n      - 'packages/cli/install.ps1'\n      - '.github/workflows/test-standalone-install.yml'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}\n  cancel-in-progress: true\n\ndefaults:\n  run:\n    shell: bash\n\nenv:\n  VITE_PLUS_VERSION: alpha\n\njobs:\n  test-install-sh:\n    name: Test install.sh (${{ matrix.name }})\n    runs-on: ${{ matrix.os }}\n    permissions:\n      contents: read\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - os: ubuntu-latest\n            name: Linux x64 glibc\n          - os: macos-15-intel\n            name: macOS x64\n          - os: macos-latest\n            name: macOS ARM64\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Run install.sh\n        run: cat packages/cli/install.sh | bash\n\n      - name: Verify installation\n        working-directory: ${{ runner.temp }}\n        run: |\n          # Source shell config to get PATH updated\n          if [ -f ~/.zshenv ]; then\n            # non-interactive shells use zshenv\n            source ~/.zshenv\n          elif [ -f ~/.zshrc ]; then\n            # interactive shells use zshrc\n            source ~/.zshrc\n          elif [ -f ~/.bash_profile ]; then\n            # non-interactive shells use bash_profile\n            source ~/.bash_profile\n          elif [ -f ~/.bashrc ]; then\n            # interactive shells use bashrc\n            source ~/.bashrc\n          else\n            export PATH=\"$HOME/.vite-plus/bin:$PATH\"\n          fi\n          echo \"PATH: $PATH\"\n          ls -al ~/\n\n          vp --version\n          vp --help\n          # test create command\n          vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla\n          cd hello && vp run build && vp --version\n\n      - name: Set PATH\n        shell: bash\n        run: |\n          echo \"$HOME/.vite-plus/bin\" >> $GITHUB_PATH\n\n      - name: Verify bin setup\n        run: |\n          # Verify bin directory was created by vp env --setup\n          BIN_PATH=\"$HOME/.vite-plus/bin\"\n          ls -al \"$BIN_PATH\"\n          if [ ! -d \"$BIN_PATH\" ]; then\n            echo \"Error: Bin directory not found: $BIN_PATH\"\n            exit 1\n          fi\n\n          # Verify shim executables exist\n          for shim in node npm npx; do\n            if [ ! -f \"$BIN_PATH/$shim\" ]; then\n              echo \"Error: Shim not found: $BIN_PATH/$shim\"\n              exit 1\n            fi\n            echo \"Found shim: $BIN_PATH/$shim\"\n          done\n\n          # Verify vp env doctor works\n          vp env doctor\n          vp env run --node 24 -- node -p \"process.versions\"\n\n          which node\n          which npm\n          which npx\n          which vp\n\n      - name: Verify upgrade\n        run: |\n          # --check queries npm registry and prints update status\n          vp upgrade --check\n          vp upgrade 0.0.0-gbe8891a5.20260227-1615\n          vp --version\n          # rollback to the previous version (should succeed after a real update)\n          vp upgrade --rollback\n          vp --version\n\n  test-install-sh-readonly-config:\n    name: Test install.sh (readonly shell config)\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Make shell config files read-only\n        run: |\n          # Simulate Nix-managed or read-only shell configs\n          touch ~/.bashrc ~/.bash_profile ~/.profile\n          chmod 444 ~/.bashrc ~/.bash_profile ~/.profile\n\n      - name: Run install.sh\n        run: |\n          output=$(cat packages/cli/install.sh | bash 2>&1) || {\n            echo \"$output\"\n            echo \"Install script exited with non-zero status\"\n            exit 1\n          }\n          echo \"$output\"\n          # Verify installation succeeds (not a fatal error)\n          echo \"$output\" | grep -q \"successfully installed\"\n          # Verify fallback message shows binary location\n          echo \"$output\" | grep -q \"vp was installed to:\"\n          # Verify fallback message shows manual instructions\n          echo \"$output\" | grep -q \"Or run vp directly:\"\n          # Verify the permission warning was shown\n          echo \"$output\" | grep -qi \"permission denied\"\n\n      - name: Verify vp works via direct path\n        run: |\n          ~/.vite-plus/bin/vp --version\n\n  test-install-sh-arm64:\n    name: Test install.sh (Linux ARM64 glibc via QEMU)\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0\n        with:\n          platforms: arm64\n\n      - name: Run install.sh in ARM64 container\n        run: |\n          docker run --rm --platform linux/arm64 \\\n            -v \"${{ github.workspace }}:/workspace\" \\\n            -e VITE_PLUS_VERSION=alpha \\\n            ubuntu:20.04 bash -c \"\n              ls -al ~/\n              apt-get update && apt-get install -y curl ca-certificates\n              cat /workspace/packages/cli/install.sh | bash\n              if [ -f ~/.profile ]; then\n                source ~/.profile\n              elif [ -f ~/.bashrc ]; then\n                source ~/.bashrc\n              else\n                export PATH=\"$HOME/.vite-plus/bin:$PATH\"\n              fi\n\n              vp --version\n              vp --help\n              vp dlx print-current-version\n\n              # Verify bin setup\n              BIN_PATH=\\\"\\$HOME/.vite-plus/bin\\\"\n              if [ ! -d \\\"\\$BIN_PATH\\\" ]; then\n                echo \\\"Error: Bin directory not found: \\$BIN_PATH\\\"\n                exit 1\n              fi\n              for shim in node npm npx; do\n                if [ ! -f \\\"\\$BIN_PATH/\\$shim\\\" ]; then\n                  echo \\\"Error: Shim not found: \\$BIN_PATH/\\$shim\\\"\n                  exit 1\n                fi\n                echo \\\"Found shim: \\$BIN_PATH/\\$shim\\\"\n              done\n              vp env doctor\n\n              export VITE_LOG=trace\n              vp env run --node 24 -- node -p \\\"process.versions\\\"\n\n              # Verify upgrade\n              vp upgrade --check\n              vp upgrade 0.0.0-gbe8891a5.20260227-1615\n              vp --version\n              vp upgrade --rollback\n              vp --version\n\n              # FIXME: qemu: uncaught target signal 11 (Segmentation fault) - core dumped\n              # vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla\n              # cd hello && vp run build\n            \"\n\n  test-install-ps1-v5:\n    name: Test install.ps1 (Windows x64, PowerShell 5.1)\n    runs-on: windows-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Assert PowerShell 5.x\n        shell: powershell\n        run: |\n          Write-Host \"PowerShell version: $($PSVersionTable.PSVersion)\"\n          if ($PSVersionTable.PSVersion.Major -ne 5) {\n            Write-Error \"Expected PowerShell 5.x but got $($PSVersionTable.PSVersion)\"\n            exit 1\n          }\n\n      - name: Run install.ps1\n        shell: powershell\n        run: |\n          & ./packages/cli/install.ps1\n\n      - name: Run install.ps1 via irm simulation (catches BOM issues)\n        shell: powershell\n        run: |\n          $ErrorActionPreference = \"Stop\"\n          Get-Content ./packages/cli/install.ps1 -Raw | Invoke-Expression\n\n      - name: Set PATH\n        shell: bash\n        run: |\n          echo \"$USERPROFILE\\.vite-plus\\bin\" >> $GITHUB_PATH\n\n      - name: Verify installation\n        shell: powershell\n        working-directory: ${{ runner.temp }}\n        run: |\n          Write-Host \"PATH: $env:Path\"\n          vp --version\n          vp --help\n          vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla\n          cd hello\n          vp run build\n          vp --version\n\n      - name: Verify bin setup\n        shell: powershell\n        run: |\n          $binPath = \"$env:USERPROFILE\\.vite-plus\\bin\"\n          Get-ChildItem -Force $binPath\n          if (-not (Test-Path $binPath)) {\n            Write-Error \"Bin directory not found: $binPath\"\n            exit 1\n          }\n\n          $expectedShims = @(\"node.exe\", \"npm.exe\", \"npx.exe\")\n          foreach ($shim in $expectedShims) {\n            $shimFile = Join-Path $binPath $shim\n            if (-not (Test-Path $shimFile)) {\n              Write-Error \"Shim not found: $shimFile\"\n              exit 1\n            }\n            Write-Host \"Found shim: $shimFile\"\n          }\n          where.exe node\n          where.exe npm\n          where.exe npx\n          where.exe vp\n\n          $env:Path = \"$env:USERPROFILE\\.vite-plus\\bin;$env:Path\"\n          vp env doctor\n          vp env run --node 24 -- node -p \"process.versions\"\n\n      - name: Verify upgrade\n        shell: powershell\n        run: |\n          vp upgrade --check\n          vp upgrade 0.0.0-gbe8891a5.20260227-1615\n          vp --version\n          vp upgrade --rollback\n          vp --version\n\n  test-install-ps1-arm64:\n    name: Test install.ps1 (Windows ARM64)\n    runs-on: windows-11-arm\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Run install.ps1\n        shell: pwsh\n        run: |\n          & ./packages/cli/install.ps1\n\n      - name: Set PATH\n        shell: bash\n        run: |\n          echo \"$USERPROFILE\\.vite-plus\\bin\" >> $GITHUB_PATH\n\n      - name: Verify installation\n        shell: pwsh\n        working-directory: ${{ runner.temp }}\n        run: |\n          Write-Host \"PATH: $env:Path\"\n          vp --version\n          vp --help\n          vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla\n          cd hello\n          vp run build\n          vp --version\n\n      - name: Verify bin setup\n        shell: pwsh\n        run: |\n          $binPath = \"$env:USERPROFILE\\.vite-plus\\bin\"\n          Get-ChildItem -Force $binPath\n          if (-not (Test-Path $binPath)) {\n            Write-Error \"Bin directory not found: $binPath\"\n            exit 1\n          }\n\n          $expectedShims = @(\"node.exe\", \"npm.exe\", \"npx.exe\")\n          foreach ($shim in $expectedShims) {\n            $shimFile = Join-Path $binPath $shim\n            if (-not (Test-Path $shimFile)) {\n              Write-Error \"Shim not found: $shimFile\"\n              exit 1\n            }\n            Write-Host \"Found shim: $shimFile\"\n          }\n          where.exe node\n          where.exe npm\n          where.exe npx\n          where.exe vp\n\n          $env:Path = \"$env:USERPROFILE\\.vite-plus\\bin;$env:Path\"\n          vp env doctor\n          vp env run --node 24 -- node -p \"process.versions\"\n\n      - name: Verify upgrade\n        shell: pwsh\n        run: |\n          vp upgrade --check\n          vp upgrade 0.0.0-gbe8891a5.20260227-1615\n          vp --version\n          vp upgrade --rollback\n          vp --version\n\n  test-install-ps1:\n    name: Test install.ps1 (Windows x64)\n    runs-on: windows-latest\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n\n      - name: Run install.ps1\n        shell: pwsh\n        run: |\n          & ./packages/cli/install.ps1\n\n      - name: Set PATH\n        shell: bash\n        run: |\n          echo \"$USERPROFILE\\.vite-plus\\bin\" >> $GITHUB_PATH\n\n      - name: Verify upgrade\n        shell: pwsh\n        run: |\n          # --check queries npm registry and prints update status\n          vp upgrade --check\n          vp upgrade 0.0.0-gbe8891a5.20260227-1615\n          vp --version\n          # rollback to the previous version (should succeed after a real update)\n          vp upgrade --rollback\n          vp --version\n\n      - name: Verify installation on powershell\n        shell: pwsh\n        working-directory: ${{ runner.temp }}\n        run: |\n          # Print PATH from environment\n          echo \"PATH: $env:Path\"\n          vp --version\n          vp --help\n          # $env:VITE_LOG = \"trace\"\n          # test create command\n          vp create vite --no-interactive --no-agent -- hello --no-interactive -t vanilla\n          cd hello && vp run build && vp --version\n\n      - name: Verify bin setup on powershell\n        shell: pwsh\n        run: |\n          # Verify bin directory was created by vp env --setup\n          $binPath = \"$env:USERPROFILE\\.vite-plus\\bin\"\n          Get-ChildItem -Force $binPath\n          if (-not (Test-Path $binPath)) {\n            Write-Error \"Bin directory not found: $binPath\"\n            exit 1\n          }\n\n          # Verify shim executables exist (trampoline .exe files on Windows)\n          $expectedShims = @(\"node.exe\", \"npm.exe\", \"npx.exe\")\n          foreach ($shim in $expectedShims) {\n            $shimFile = Join-Path $binPath $shim\n            if (-not (Test-Path $shimFile)) {\n              Write-Error \"Shim not found: $shimFile\"\n              exit 1\n            }\n            Write-Host \"Found shim: $shimFile\"\n          }\n          where.exe node\n          where.exe npm\n          where.exe npx\n          where.exe vp\n\n          # Verify vp env doctor works\n          $env:Path = \"$env:USERPROFILE\\.vite-plus\\bin;$env:Path\"\n          vp env doctor\n          vp env run --node 24 -- node -p \"process.versions\"\n\n      - name: Verify installation on cmd\n        shell: cmd\n        working-directory: ${{ runner.temp }}\n        run: |\n          echo PATH: %PATH%\n          dir \"%USERPROFILE%\\.vite-plus\"\n          dir \"%USERPROFILE%\\.vite-plus\\bin\"\n\n          REM test create command\n          vp create vite --no-interactive --no-agent -- hello-cmd --no-interactive -t vanilla\n          cd hello-cmd && vp run build && vp --version\n\n      - name: Verify bin setup on cmd\n        shell: cmd\n        run: |\n          REM Verify bin directory was created by vp env --setup\n          set \"BIN_PATH=%USERPROFILE%\\.vite-plus\\bin\"\n          dir \"%BIN_PATH%\"\n\n          REM Verify shim executables exist (Windows uses trampoline .exe files)\n          for %%s in (node.exe npm.exe npx.exe vp.exe) do (\n            if not exist \"%BIN_PATH%\\%%s\" (\n              echo Error: Shim not found: %BIN_PATH%\\%%s\n              exit /b 1\n            )\n            echo Found shim: %BIN_PATH%\\%%s\n          )\n\n          where node\n          where npm\n          where npx\n          where vp\n\n          REM Verify vp env doctor works\n          vp env doctor\n          vp env run --node 24 -- node -p \"process.versions\"\n\n      - name: Verify installation on bash\n        shell: bash\n        working-directory: ${{ runner.temp }}\n        run: |\n          echo \"PATH: $PATH\"\n          ls -al ~/.vite-plus\n          ls -al ~/.vite-plus/bin\n\n          vp --version\n          vp --help\n          # test create command\n          vp create vite --no-interactive --no-agent -- hello-bash --no-interactive -t vanilla\n          cd hello-bash && vp run build && vp --version\n\n      - name: Verify bin setup on bash\n        shell: bash\n        run: |\n          # Verify bin directory was created by vp env --setup\n          BIN_PATH=\"$HOME/.vite-plus/bin\"\n          ls -al \"$BIN_PATH\"\n          if [ ! -d \"$BIN_PATH\" ]; then\n            echo \"Error: Bin directory not found: $BIN_PATH\"\n            exit 1\n          fi\n\n          # Verify trampoline .exe files exist\n          for shim in node.exe npm.exe npx.exe vp.exe; do\n            if [ ! -f \"$BIN_PATH/$shim\" ]; then\n              echo \"Error: Trampoline shim not found: $BIN_PATH/$shim\"\n              exit 1\n            fi\n            echo \"Found trampoline shim: $BIN_PATH/$shim\"\n          done\n\n          # Verify vp env doctor works\n          vp env doctor\n          vp env run --node 24 -- node -p \"process.versions\"\n\n          which node\n          which npm\n          which npx\n          which vp\n"
  },
  {
    "path": ".github/workflows/upgrade-deps.yml",
    "content": "name: Upgrade Upstream Dependencies\n\non:\n  schedule:\n    - cron: '0 0 * * *' # Daily at midnight UTC\n  workflow_dispatch: # Manual trigger\n\npermissions: {}\n\njobs:\n  upgrade:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n      actions: read\n      id-token: write\n    steps:\n      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1\n      - uses: ./.github/actions/clone\n\n      - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0\n        with:\n          save-cache: ${{ github.ref_name == 'main' }}\n          cache-key: upgrade-deps\n          tools: just,cargo-shear\n\n      - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4\n\n      - name: Rustup Adds Target\n        run: rustup target add x86_64-unknown-linux-gnu\n\n      - name: Rustup Adds Target for rolldown\n        working-directory: rolldown\n        run: rustup target add x86_64-unknown-linux-gnu\n\n      - name: Upgrade dependencies\n        id: upgrade\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: node .github/scripts/upgrade-deps.mjs\n\n      - name: Sync remote and build\n        id: build\n        continue-on-error: true # Create PR even if build fails\n        run: |\n          pnpm install --no-frozen-lockfile\n          pnpm tool sync-remote\n          pnpm install --no-frozen-lockfile\n\n      - name: Build\n        uses: ./.github/actions/build-upstream\n        id: build-upstream\n        continue-on-error: true\n        with:\n          target: x86_64-unknown-linux-gnu\n          print-after-build: 'true'\n        env:\n          RELEASE_BUILD: 'true'\n\n      - uses: anthropics/claude-code-action@eb99fb38f09dedf69f423f1315d6c0272ace56a0 # Claude Code to 2.1.72\n        env:\n          RELEASE_BUILD: 'true'\n        with:\n          claude_code_oauth_token: ${{ secrets.ANTHROPIC_API_KEY }}\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          show_full_output: 'true'\n          prompt: |\n            Check if the build-upstream steps failed and fix them.\n            ### Background\n            - The build-upstream steps are at ./.github/actions/build-upstream/action.yml\n            - The deps upgrade script is at ./.github/scripts/upgrade-deps.mjs\n\n            ### Instructions\n            - We are using `pnpm` as the package manager\n            - We are aiming to upgrade all dependencies to the latest versions in this workflow, so don't downgrade any dependencies.\n            - Compare tsdown CLI options with `vp pack` and sync any new or removed options. Follow the instructions in `.claude/skills/sync-tsdown-cli/SKILL.md`.\n            - Check `.claude/agents/cargo-workspace-merger.md` if rolldown hash is changed.\n            - Run the steps in `build-upstream` action.yml after your fixing. If no errors are found, you can safe to exit.\n            - Install global CLI after the build-upstream steps are successful, by running the following commands:\n              - `pnpm bootstrap-cli:ci`\n              - `echo \"$HOME/.vite-plus/bin\" >> $GITHUB_PATH`\n            - Run `pnpm run lint` to check if there are any issues after the build, if has, deep investigate it and fix it. You need to run `just build` before you can run `pnpm run lint`.\n            - Run `pnpm run test` after `just build` to ensure all tests are successful.\n            - The snapshot tests in `pnpm run test` are always successful, you need to check the snapshot diffs in git to see if there is anything wrong after our deps upgrade.\n            - If deps in our `Cargo.toml` need to be upgraded, you can refer to the `./.claude/agents/cargo-workspace-merger.md`\n              - If `Cargo.toml` has been modified, you need to run `cargo shear` to ensure there is nothing wrong with our dependencies.\n              - Run `cargo check --all-targets --all-features` to ensure everything works fine if any Rust related codes are modified.\n            - Run the following commands to ensure everything works fine:\n              vp -h\n              vp run -h\n              vp lint -h\n              vp test -h\n              vp build -h\n              vp fmt -h\n              vp pack -h\n            - Your final step is to run `just build` to ensure all builds are successful.\n\n            Help me fix the errors in `build-upstream` steps if exists.\n            No need to commit changes after your fixing we have a following step to commit all file changes.\n          claude_args: |\n            --model opus --allowedTools \"Bash,Edit,Replace,NotebookEditCell\"\n          additional_permissions: |\n            actions: read\n\n      - name: Update lockfile\n        run: |\n          pnpm install --no-frozen-lockfile\n          pnpm dedupe\n\n      - name: Checkout binding files\n        run: |\n          git checkout packages/cli/binding/index.cjs\n          git checkout packages/cli/binding/index.d.cts\n\n      - name: Format code\n        run: pnpm fmt\n\n      - name: Close and delete previous PR\n        env:\n          GH_TOKEN: ${{ secrets.AUTO_UPDATE_BRANCH_TOKEN }}\n        run: |\n          # Find PR with the deps/upstream-update branch\n          PR_NUMBER=$(gh pr list --head deps/upstream-update --json number --jq '.[0].number')\n\n          if [ -n \"$PR_NUMBER\" ]; then\n            echo \"Found existing PR #$PR_NUMBER, closing and deleting branch…\"\n            gh pr close \"$PR_NUMBER\" --delete-branch\n          else\n            echo \"No existing PR found with branch deps/upstream-update\"\n          fi\n\n      - name: Create/Update PR\n        uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11\n        with:\n          base: main\n          branch: deps/upstream-update\n          title: 'feat(deps): upgrade upstream dependencies'\n          sign-commits: true\n          token: ${{ secrets.AUTO_UPDATE_BRANCH_TOKEN }}\n          branch-token: ${{ secrets.GITHUB_TOKEN }}\n          body: |\n            Automated daily upgrade of upstream dependencies:\n            - rolldown (latest tag)\n            - vite (latest tag)\n            - vitest (latest npm version)\n            - tsdown (latest npm version)\n\n            Build status: ${{ steps.build.outcome }}\n          commit-message: 'feat(deps): upgrade upstream dependencies'\n"
  },
  {
    "path": ".github/workflows/zizmor.yml",
    "content": "name: Zizmor\n\npermissions: {}\n\non:\n  workflow_dispatch:\n  pull_request:\n    types: [opened, synchronize]\n    paths:\n      - '.github/workflows/**'\n  push:\n    branches:\n      - main\n      - 'renovate/**'\n    paths:\n      - '.github/workflows/**'\n\njobs:\n  zizmor:\n    name: zizmor\n    runs-on: ubuntu-latest\n    permissions:\n      security-events: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4\n        with:\n          persist-credentials: false\n          submodules: true\n\n      - uses: taiki-e/install-action@ae97ff9daf1cd2e216671a047d80ff48461e30bb # v2.49.1\n        with:\n          tool: zizmor\n\n      - name: Run zizmor\n        run: zizmor --format sarif . > results.sarif\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Upload SARIF file\n        uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3\n        with:\n          sarif_file: results.sarif\n          category: zizmor\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\nnode_modules\ndist\n.claude/settings.local.json\n*.tsbuildinfo\n.DS_Store\nrolldown\nrolldown-vite\nvite\n/crates/vite_global_cli/vp\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm lint-staged"
  },
  {
    "path": ".node-version",
    "content": "22.18.0\n"
  },
  {
    "path": ".rustfmt.toml",
    "content": "style_edition = \"2024\"\n\n# Make Rust more readable given most people have wide screens nowadays.\n# This is also the setting used by [rustc](https://github.com/rust-lang/rust/blob/master/rustfmt.toml)\nuse_small_heuristics = \"Max\"\n\n# Use field initialize shorthand if possible\nuse_field_init_shorthand = true\nreorder_modules = true\n\n# All unstable features that we wish for\n# unstable_features = true\n# Provide a cleaner impl order\nreorder_impl_items = true\n# Provide a cleaner import sort order\ngroup_imports = \"StdExternalCrate\"\n# Group \"use\" statements by crate\nimports_granularity = \"Crate\"\n"
  },
  {
    "path": ".typos.toml",
    "content": "[default.extend-words]\nratatui = \"ratatui\"\nPUNICODE = \"PUNICODE\"\nJod = \"Jod\" # Node.js v22 LTS codename\n\n[files]\nextend-exclude = [\n  \"**/snap-tests/**/snap.txt\",\n  \"crates/fspy_detours_sys/detours\",\n  \"crates/fspy_detours_sys/src/generated_bindings.rs\",\n  \"packages/cli/src/oxfmt-config.ts\",\n]\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"VoidZero.vite-plus-extension-pack\"]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"editor.formatOnSave\": true,\n  \"files.insertFinalNewline\": true,\n  \"files.trimFinalNewlines\": true,\n  \"[javascript]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[typescriptreact]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"[json]\": {\n    \"editor.defaultFormatter\": \"oxc.oxc-vscode\"\n  },\n  \"typescript.preferences.importModuleSpecifierEnding\": \"js\",\n  \"typescript.reportStyleChecksAsWarnings\": false,\n  \"typescript.updateImportsOnFileMove.enabled\": \"always\",\n  \"typescript.experimental.useTsgo\": true\n}\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# Vite-Plus\n\nA monorepo task runner (like nx/turbo) with intelligent caching and dependency resolution.\n\n## Core Concept\n\n**Task Execution**: Run tasks across monorepo packages with automatic dependency ordering.\n\n```bash\n# Built-in commands\nvp build                           # Run Vite build (dedicated command)\nvp test                            # Run Vitest (dedicated command)\nvp lint                            # Run oxlint (dedicated command)\n\n# Run tasks across packages (explicit mode)\nvp run build -r                    # recursive with topological ordering\nvp run app#build web#build         # specific packages\nvp run build -r --no-topological   # recursive without implicit deps\n\n# Run task in current package (implicit mode - for non-built-in tasks)\nvp run dev                         # runs dev script from package.json\n```\n\n## Key Architecture\n\n- **Entry**: `crates/vite_task/src/lib.rs` - CLI parsing and main logic\n- **Workspace**: `crates/vite_task/src/config/workspace.rs` - Loads packages, creates task graph\n- **Task Graph**: `crates/vite_task/src/config/task_graph_builder.rs` - Builds dependency graph\n- **Execution**: `crates/vite_task/src/schedule.rs` - Executes tasks in dependency order\n\n## Task Dependencies\n\n1. **Explicit** (always applied): Defined in `vite-task.json`\n\n   ```json\n   {\n     \"tasks\": {\n       \"test\": {\n         \"command\": \"jest\",\n         \"dependsOn\": [\"build\", \"lint\"]\n       }\n     }\n   }\n   ```\n\n2. **Implicit** (when `--topological`): Based on package.json dependencies\n   - If A depends on B, then A#build depends on B#build automatically\n\n## Key Features\n\n- **Topological Flag**: Controls implicit dependencies from package relationships\n  - Default: ON for `--recursive`, OFF otherwise\n  - Toggle with `--no-topological` to disable\n\n- **Boolean Flags**: All support `--no-*` pattern for explicit disable\n  - Example: `--recursive` vs `--no-recursive`\n  - Conflicts handled by clap\n  - If you want to add a new boolean flag, follow this pattern\n\n## Path Type System\n\n- **Type Safety**: All paths use typed `vite_path` instead of `std::path` for better safety\n  - **Absolute Paths**: `vite_path::AbsolutePath` / `AbsolutePathBuf`\n  - **Relative Paths**: `vite_path::RelativePath` / `RelativePathBuf`\n\n- **Usage Guidelines**:\n  - Use methods such as `strip_prefix`/`join` provided in `vite_path` for path operations instead of converting to std paths\n  - Only convert to std paths when interfacing with std library functions, and this should be implicit in most cases thanks to `AsRef<Path>` implementations\n  - Add necessary methods in `vite_path` instead of falling back to std path types\n\n- **Converting from std paths** (e.g., `TempDir::path()`):\n\n  ```rust\n  let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n  ```\n\n- **Function signatures**: Prefer `&AbsolutePath` over `&std::path::Path`\n\n- **Passing to std functions**: `AbsolutePath` implements `AsRef<Path>`, use `.as_path()` when explicit `&Path` is required\n\n## Clippy Rules\n\nAll **new** Rust code must follow the custom clippy rules defined in `.clippy.toml` (disallowed types, macros, and methods). Existing code may not fully comply due to historical reasons.\n\n## CLI Output\n\nAll user-facing output must go through shared output modules instead of raw print calls.\n\n- **Rust**: Use `vite_shared::output` functions (`info`, `warn`, `error`, `note`, `success`) — never raw `println!`/`eprintln!` (enforced by clippy `disallowed-macros`)\n- **TypeScript**: Use `packages/cli/src/utils/terminal.ts` functions (`infoMsg`, `warnMsg`, `errorMsg`, `noteMsg`, `log`) — never raw `console.log`/`console.error`\n\n## Git Workflow\n\n- Run `vp check --fix` before committing to format and lint code\n\n## Quick Reference\n\n- **Compound Commands**: `\"build\": \"tsc && rollup\"` splits into subtasks\n- **Task Format**: `package#task` (e.g., `app#build`)\n- **Path Types**: Use `vite_path` types instead of `std::path` types for type safety\n- **Tests**: Run `cargo test -p vite_task` to verify changes\n- **Debug**: Use `--debug` to see cache operations\n\n## Tests\n\n- Run `cargo test` to execute all tests\n- You never need to run `pnpm install` in the test fixtures dir, vite-plus should able to load and parse the workspace without `pnpm install`.\n\n## Build\n\n- Run `pnpm bootstrap-cli` from the project root to build all packages and install the global CLI\n  - This builds all `@voidzero-dev/*` and `vite-plus` packages\n  - Compiles the Rust NAPI bindings and the `vp` Rust binary\n  - Installs the CLI globally to `~/.vite-plus/`\n\n## Snap Tests\n\nSnap tests are located in `packages/cli/snap-tests/` (local CLI) and `packages/cli/snap-tests-global/` (global CLI). Each test case is a directory containing:\n\n- `package.json` - Package configuration for the test\n- `steps.json` - Commands to run and environment variables\n- `src/` - Source files for the test\n- `snap.txt` - Expected output (generated/updated by running the test)\n\n```bash\n# Run all snap tests (local + global)\npnpm -F vite-plus snap-test\n\n# Run only local CLI snap tests\npnpm -F vite-plus snap-test-local\npnpm -F vite-plus snap-test-local <name-filter>\n\n# Run only global CLI snap tests\npnpm -F vite-plus snap-test-global\npnpm -F vite-plus snap-test-global <name-filter>\n```\n\nThe snap test will automatically generate/update the `snap.txt` file with the command outputs. It exits with zero status even if there are output differences; you need to manually check the diffs(`git diff`) to verify correctness.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing Guide\n\n## Initial Setup\n\n### macOS / Linux\n\nYou'll need the following tools installed on your system:\n\n```\nbrew install pnpm node just cmake\n```\n\nInstall Rust & Cargo using rustup:\n\n```\ncurl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh\ncargo install cargo-binstall\n```\n\nInitial setup to install dependencies for Vite+:\n\n```\njust init\n```\n\n### Windows\n\nYou'll need the following tools installed on your system. You can use [winget](https://learn.microsoft.com/en-us/windows/package-manager/).\n\n```powershell\nwinget install pnpm.pnpm OpenJS.NodeJS.LTS Casey.Just Kitware.CMake\n```\n\nInstall Rust & Cargo from [rustup.rs](https://rustup.rs/), then install `cargo-binstall`:\n\n```powershell\ncargo install cargo-binstall\n```\n\nInitial setup to install dependencies for Vite+:\n\n```powershell\njust init\n```\n\n**Note:** Run commands in PowerShell or Windows Terminal. Some commands may require elevated permissions.\n\n## Build Vite+ and upstream dependencies\n\nTo create a release build of Vite+ and all upstream dependencies, run:\n\n```\njust build\n```\n\n## Install the Vite+ Global CLI from source code\n\n```\npnpm bootstrap-cli\nvp --version\n```\n\nThis builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus`.\n\n## Workflow for build and test\n\nYou can run this command to build, test and check if there are any snapshot changes:\n\n```\npnpm bootstrap-cli && pnpm test && git status\n```\n\n## Running Snap Tests\n\nSnap tests verify CLI output. They are located in `packages/cli/snap-tests/` (local CLI) and `packages/cli/snap-tests-global/` (global CLI).\n\n```bash\n# Run all snap tests (local + global)\npnpm -F vite-plus snap-test\n\n# Run only local CLI snap tests\npnpm -F vite-plus snap-test-local\npnpm -F vite-plus snap-test-local <name-filter>\n\n# Run only global CLI snap tests\npnpm -F vite-plus snap-test-global\npnpm -F vite-plus snap-test-global <name-filter>\n```\n\nSnap tests auto-generate `snap.txt` files. Check `git diff` to verify output changes are correct.\n\n## Verified Commits\n\nAll commits in PR branches should be GitHub-verified so reviewers can confirm commit authenticity.\n\nSet up local commit signing and GitHub verification first:\n\n- Follow GitHub's guide for GPG commit signature verification: https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#gpg-commit-signature-verification\n- If you use Graphite, add the Graphite GPG key to your GitHub account from the Graphite UI as well, otherwise commits updated by Graphite won't show as verified.\n\nAfter setup, re-sign any existing commits in your branch so the full branch is verified:\n\n```bash\n# Re-sign each commit on your branch (replace origin/main with your branch base if needed)\ngit rebase -i origin/main\n# At each stop:\ngit commit --amend --date=now --no-edit -S\n# Then continue:\ngit rebase --continue\n```\n\nWhen done, force-push the updated branch history:\n\n```bash\ngit push --force-with-lease\n```\n\n## Pull upstream dependencies\n\n> [!NOTE]\n>\n> Upstream dependencies only need to be updated when an [\"upgrade upstream dependencies\"](https://github.com/voidzero-dev/vite-plus/pulls?q=is%3Apr+feat%28deps%29%3A+upgrade+upstream+dependencies+merged) pull request is merged.\n\nTo sync the latest upstream dependencies such as Rolldown and Vite, run:\n\n```\npnpm tool sync-remote\njust build\n```\n\n## macOS Performance Tip\n\nIf you are using macOS, add your terminal app (Ghostty, iTerm2, Terminal, …) to the approved \"Developer Tools\" apps in the Privacy panel of System Settings and restart your terminal app. Your Rust builds will be about ~30% faster.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nresolver = \"3\"\nmembers = [\"bench\", \"crates/*\", \"packages/cli/binding\"]\n\n[workspace.metadata.cargo-shear]\nignored = [\n  # These workspace dependencies are used by rolldown crates, not our local crates\n  \"css-module-lexer\",\n  \"html5gum\",\n  \"rolldown_filter_analyzer\",\n  \"rolldown_plugin_vite_asset\",\n  \"rolldown_plugin_vite_asset_import_meta_url\",\n  \"rolldown_plugin_vite_css\",\n  \"rolldown_plugin_vite_css_post\",\n  \"rolldown_plugin_vite_html\",\n  \"rolldown_plugin_vite_html_inline_proxy\",\n  \"string_cache\",\n]\n\n[workspace.package]\nauthors = [\"Vite+ Authors\"]\nedition = \"2024\"\nhomepage = \"https://github.com/voidzero-dev/vite-plus\"\nlicense = \"MIT\"\nrepository = \"https://github.com/voidzero-dev/vite-plus\"\nrust-version = \"1.92.0\"\n\n[workspace.lints.rust]\nabsolute_paths_not_starting_with_crate = \"warn\"\nnon_ascii_idents = \"warn\"\nunit-bindings = \"warn\"\nunexpected_cfgs = { level = \"warn\", check-cfg = ['cfg(coverage)', 'cfg(coverage_nightly)'] }\nunsafe_op_in_unsafe_fn = \"warn\"\nunused_unsafe = \"warn\"\n\n[workspace.lints.clippy]\nall = { level = \"warn\", priority = -1 }\n# restriction\ndbg_macro = \"warn\"\ntodo = \"warn\"\nunimplemented = \"warn\"\nprint_stdout = \"warn\"\nprint_stderr = \"warn\"\nallow_attributes = \"warn\"\npedantic = { level = \"warn\", priority = -1 }\nnursery = { level = \"warn\", priority = -1 }\ncargo = { level = \"warn\", priority = -1 }\ncargo_common_metadata = \"allow\"\n\n[workspace.dependencies]\nanyhow = \"1.0.98\"\nappend-only-vec = \"0.1.7\"\narcstr = { version = \"1.2.0\", default-features = false }\nariadne = { package = \"rolldown-ariadne\", version = \"0.5.3\" }\nast-grep-config = \"0.40.1\"\nast-grep-core = \"0.40.1\"\nast-grep-language = { version = \"0.40.1\", default-features = false, features = [\n  \"tree-sitter-bash\",\n  \"tree-sitter-typescript\",\n] }\nasync-channel = \"2.3.1\"\nasync-scoped = \"0.9.0\"\nasync-trait = \"0.1.89\"\nbackon = \"1.3.0\"\nbase-encode = \"0.3.1\"\nbase64-simd = \"0.8.0\"\nbincode = \"2.0.1\"\nbstr = { version = \"1.12.0\", default-features = false, features = [\"alloc\", \"std\"] }\nbitflags = \"2.9.1\"\nbrush-parser = \"0.3.0\"\nblake3 = \"1.8.2\"\nchrono = { version = \"0.4\", features = [\"serde\"] }\nclap = \"4.5.40\"\nclap_complete = \"4.6.0\"\ncommondir = \"1.0.0\"\ncow-utils = \"0.1.3\"\ncriterion = { version = \"0.7\", features = [\"html_reports\"] }\ncriterion2 = { version = \"3.0.0\", default-features = false }\ncrossterm = { version = \"0.29.0\", features = [\"event-stream\"] }\ncss-module-lexer = \"0.0.15\"\ndashmap = \"6.1.0\"\nderive_more = { version = \"2.0.1\", features = [\"debug\"] }\ndirectories = \"6.0.0\"\ndunce = \"1.0.5\"\nfast-glob = \"1.0.0\"\nflate2 = { version = \"=1.1.9\", features = [\"zlib-rs\"] }\nform_urlencoded = \"1.2.1\"\nfspy = { git = \"https://github.com/voidzero-dev/vite-task.git\", rev = \"69cc6eba95a3b7f25f7d4d32c3f29b1386995907\" }\nfutures = \"0.3.31\"\nfutures-util = \"0.3.31\"\nglob = \"0.3.2\"\nheck = \"0.5.0\"\nhex = \"0.4.3\"\nhtml5gum = \"0.8.1\"\nhttpmock = \"0.7\"\nignore = \"0.4\"\nindicatif = \"0.18\"\nindexmap = \"2.9.0\"\nindoc = \"2.0.5\"\ninfer = \"0.19.0\"\ninsta = \"1.43.1\"\nitertools = \"0.14.0\"\nitoa = \"1.0.15\"\njson-escape-simd = \"3\"\njson-strip-comments = \"3\"\njsonschema = { version = \"0.45.0\", default-features = false }\njunction = \"1.4.1\"\nmemchr = \"2.7.4\"\nmimalloc-safe = \"0.1.52\"\nmime = \"0.3.17\"\nnapi = { version = \"3.0.0\", default-features = false, features = [\n  \"async\",\n  \"error_anyhow\",\n  \"anyhow\",\n  \"tracing\",\n  \"object_indexmap\",\n] }\nnapi-build = \"2\"\nnapi-derive = { version = \"3.0.0\", default-features = false, features = [\n  \"type-def\",\n  \"strict\",\n  \"tracing\",\n] }\nnix = { version = \"0.30.1\", features = [\"dir\"] }\nnodejs-built-in-modules = \"1.0.0\"\nnom = \"8.0.0\"\nnum-bigint = \"0.4.6\"\nnum-format = \"0.4\"\nnum_cpus = \"1.17\"\nowo-colors = \"4.2.2\"\nparking_lot = \"0.12.5\"\npathdiff = \"0.2.3\"\npnp = \"0.12.7\"\npercent-encoding = \"2.3.1\"\npetgraph = \"0.8.2\"\npretty_assertions = \"1.4.1\"\nphf = \"0.13.0\"\nrayon = \"1.10.0\"\nregex = \"1.11.1\"\nregress = \"0.11.0\"\nreqwest = { version = \"0.12\", default-features = false }\nrolldown-notify = \"10.2.0\"\nrolldown-notify-debouncer-full = \"0.7.5\"\nropey = \"1.6.1\"\nrusqlite = { version = \"0.37.0\", features = [\"bundled\"] }\nrustc-hash = \"2.1.1\"\nschemars = \"1.0.0\"\nself_cell = \"1.2.0\"\nnode-semver = \"2.2.0\"\nsemver = \"1.0.26\"\nserde = { version = \"1.0.219\", features = [\"derive\"] }\nserde_json = \"1.0.140\"\nserde_yaml = \"0.9.34\"\nserde_yml = \"0.0.12\"\nserial_test = \"3.2.0\"\nsha1 = \"0.10.6\"\nsha2 = \"0.10.9\"\nsimdutf8 = \"0.1.5\"\nsmallvec = \"1.15.0\"\nstring_cache = \"0.9.0\"\nsugar_path = { version = \"2.0.1\", features = [\"cached_current_dir\"] }\ntar = \"0.4.43\"\ntempfile = \"3.14.0\"\nterminal_size = \"0.4.2\"\ntest-log = { version = \"0.2.18\", features = [\"trace\"] }\ntesting_macros = \"1.0.0\"\nthiserror = \"2\"\ntokio = { version = \"1.48.0\", default-features = false }\ntracing = \"0.1.41\"\ntracing-chrome = \"0.7.2\"\ntracing-subscriber = { version = \"0.3.19\", default-features = false, features = [\n  \"env-filter\",\n  \"fmt\",\n  \"json\",\n  \"serde\",\n  \"std\",\n] }\nts-rs = \"12.0\"\ntypedmap = \"0.6.0\"\nurl = \"2.5.4\"\nurlencoding = \"2.1.3\"\nuuid = \"1.17.0\"\nvfs = \"0.13.0\"\nvite_command = { path = \"crates/vite_command\" }\nvite_error = { path = \"crates/vite_error\" }\nvite_js_runtime = { path = \"crates/vite_js_runtime\" }\nvite_glob = { git = \"https://github.com/voidzero-dev/vite-task.git\", rev = \"69cc6eba95a3b7f25f7d4d32c3f29b1386995907\" }\nvite_install = { path = \"crates/vite_install\" }\nvite_migration = { path = \"crates/vite_migration\" }\nvite_shared = { path = \"crates/vite_shared\" }\nvite_static_config = { path = \"crates/vite_static_config\" }\nvite_path = { git = \"https://github.com/voidzero-dev/vite-task.git\", rev = \"69cc6eba95a3b7f25f7d4d32c3f29b1386995907\" }\nvite_str = { git = \"https://github.com/voidzero-dev/vite-task.git\", rev = \"69cc6eba95a3b7f25f7d4d32c3f29b1386995907\" }\nvite_task = { git = \"https://github.com/voidzero-dev/vite-task.git\", rev = \"69cc6eba95a3b7f25f7d4d32c3f29b1386995907\" }\nvite_workspace = { git = \"https://github.com/voidzero-dev/vite-task.git\", rev = \"69cc6eba95a3b7f25f7d4d32c3f29b1386995907\" }\nwalkdir = \"2.5.0\"\nwax = \"0.6.0\"\nwhich = \"8.0.0\"\nxxhash-rust = \"0.8.15\"\nzip = \"7.2\"\n\n# oxc crates with the same version\noxc = { version = \"0.121.0\", features = [\n  \"ast_visit\",\n  \"transformer\",\n  \"minifier\",\n  \"mangler\",\n  \"semantic\",\n  \"codegen\",\n  \"serialize\",\n  \"isolated_declarations\",\n  \"regular_expression\",\n  \"cfg\",\n] }\noxc_allocator = { version = \"0.121.0\", features = [\"pool\"] }\noxc_ast = \"0.121.0\"\noxc_ecmascript = \"0.121.0\"\noxc_parser = \"0.121.0\"\noxc_span = \"0.121.0\"\noxc_napi = \"0.121.0\"\noxc_minify_napi = \"0.121.0\"\noxc_parser_napi = \"0.121.0\"\noxc_transform_napi = \"0.121.0\"\noxc_traverse = \"0.121.0\"\n\n# oxc crates in their own repos\noxc_index = { version = \"4\", features = [\"rayon\", \"serde\"] }\noxc_resolver = { version = \"11.19.1\", features = [\"yarn_pnp\"] }\noxc_resolver_napi = { version = \"11.19.1\", default-features = false, features = [\"yarn_pnp\"] }\noxc_sourcemap = \"6\"\n\n# rolldown crates\nrolldown = { path = \"./rolldown/crates/rolldown\" }\nrolldown_binding = { path = \"./rolldown/crates/rolldown_binding\" }\nrolldown_common = { path = \"./rolldown/crates/rolldown_common\" }\nrolldown_dev = { path = \"./rolldown/crates/rolldown_dev\" }\nrolldown_dev_common = { path = \"./rolldown/crates/rolldown_dev_common\" }\nrolldown_devtools = { path = \"./rolldown/crates/rolldown_devtools\" }\nrolldown_devtools_action = { path = \"./rolldown/crates/rolldown_devtools_action\" }\nrolldown_ecmascript = { path = \"./rolldown/crates/rolldown_ecmascript\" }\nrolldown_ecmascript_utils = { path = \"./rolldown/crates/rolldown_ecmascript_utils\" }\nrolldown_error = { path = \"./rolldown/crates/rolldown_error\" }\nrolldown_filter_analyzer = { path = \"./rolldown/crates/rolldown_filter_analyzer\" }\nrolldown_fs = { path = \"./rolldown/crates/rolldown_fs\" }\nrolldown_fs_watcher = { path = \"./rolldown/crates/rolldown_fs_watcher\" }\nrolldown_plugin = { path = \"./rolldown/crates/rolldown_plugin\" }\nrolldown_plugin_asset_module = { path = \"./rolldown/crates/rolldown_plugin_asset_module\" }\nrolldown_plugin_bundle_analyzer = { path = \"./rolldown/crates/rolldown_plugin_bundle_analyzer\" }\nrolldown_plugin_chunk_import_map = { path = \"./rolldown/crates/rolldown_plugin_chunk_import_map\" }\nrolldown_plugin_copy_module = { path = \"./rolldown/crates/rolldown_plugin_copy_module\" }\nrolldown_plugin_data_url = { path = \"./rolldown/crates/rolldown_plugin_data_url\" }\nrolldown_plugin_esm_external_require = { path = \"./rolldown/crates/rolldown_plugin_esm_external_require\" }\nrolldown_plugin_hmr = { path = \"./rolldown/crates/rolldown_plugin_hmr\" }\nrolldown_plugin_isolated_declaration = { path = \"./rolldown/crates/rolldown_plugin_isolated_declaration\" }\nrolldown_plugin_lazy_compilation = { path = \"./rolldown/crates/rolldown_plugin_lazy_compilation\" }\nrolldown_plugin_oxc_runtime = { path = \"./rolldown/crates/rolldown_plugin_oxc_runtime\" }\nrolldown_plugin_replace = { path = \"./rolldown/crates/rolldown_plugin_replace\" }\nrolldown_plugin_utils = { path = \"./rolldown/crates/rolldown_plugin_utils\" }\nrolldown_plugin_vite_alias = { path = \"./rolldown/crates/rolldown_plugin_vite_alias\" }\nrolldown_plugin_vite_asset = { path = \"./rolldown/crates/rolldown_plugin_vite_asset\" }\nrolldown_plugin_vite_asset_import_meta_url = { path = \"./rolldown/crates/rolldown_plugin_vite_asset_import_meta_url\" }\nrolldown_plugin_vite_build_import_analysis = { path = \"./rolldown/crates/rolldown_plugin_vite_build_import_analysis\" }\nrolldown_plugin_vite_css = { path = \"./rolldown/crates/rolldown_plugin_vite_css\" }\nrolldown_plugin_vite_css_post = { path = \"./rolldown/crates/rolldown_plugin_vite_css_post\" }\nrolldown_plugin_vite_dynamic_import_vars = { path = \"./rolldown/crates/rolldown_plugin_vite_dynamic_import_vars\" }\nrolldown_plugin_vite_html = { path = \"./rolldown/crates/rolldown_plugin_vite_html\" }\nrolldown_plugin_vite_html_inline_proxy = { path = \"./rolldown/crates/rolldown_plugin_vite_html_inline_proxy\" }\nrolldown_plugin_vite_import_glob = { path = \"./rolldown/crates/rolldown_plugin_vite_import_glob\" }\nrolldown_plugin_vite_json = { path = \"./rolldown/crates/rolldown_plugin_vite_json\" }\nrolldown_plugin_vite_load_fallback = { path = \"./rolldown/crates/rolldown_plugin_vite_load_fallback\" }\nrolldown_plugin_vite_manifest = { path = \"./rolldown/crates/rolldown_plugin_vite_manifest\" }\nrolldown_plugin_vite_module_preload_polyfill = { path = \"./rolldown/crates/rolldown_plugin_vite_module_preload_polyfill\" }\nrolldown_plugin_vite_react_refresh_wrapper = { path = \"./rolldown/crates/rolldown_plugin_vite_react_refresh_wrapper\" }\nrolldown_plugin_vite_reporter = { path = \"./rolldown/crates/rolldown_plugin_vite_reporter\" }\nrolldown_plugin_vite_resolve = { path = \"./rolldown/crates/rolldown_plugin_vite_resolve\" }\nrolldown_plugin_vite_transform = { path = \"./rolldown/crates/rolldown_plugin_vite_transform\" }\nrolldown_plugin_vite_wasm_fallback = { path = \"./rolldown/crates/rolldown_plugin_vite_wasm_fallback\" }\nrolldown_plugin_vite_web_worker_post = { path = \"./rolldown/crates/rolldown_plugin_vite_web_worker_post\" }\nrolldown_resolver = { path = \"./rolldown/crates/rolldown_resolver\" }\nrolldown_sourcemap = { path = \"./rolldown/crates/rolldown_sourcemap\" }\nrolldown_std_utils = { path = \"./rolldown/crates/rolldown_std_utils\" }\nrolldown_testing = { path = \"./rolldown/crates/rolldown_testing\" }\nrolldown_testing_config = { path = \"./rolldown/crates/rolldown_testing_config\" }\nrolldown_tracing = { path = \"./rolldown/crates/rolldown_tracing\" }\nrolldown_utils = { path = \"./rolldown/crates/rolldown_utils\" }\nrolldown_watcher = { path = \"./rolldown/crates/rolldown_watcher\" }\nrolldown_workspace = { path = \"./rolldown/crates/rolldown_workspace\" }\nstring_wizard = { path = \"./rolldown/crates/string_wizard\", features = [\"serde\"] }\n\n# =============================================================================\n# Local Development Patches\n# =============================================================================\n# This section patches vite-task crates to use local paths for simultaneous\n# vite-task and vite-plus development. When making changes to vite-task that\n# affect vite-plus, this allows testing without publishing or pushing commits.\n#\n# To use: Ensure vite-task is cloned at ../vite-task relative to vite-plus.\n# Comment out this section before committing.\n# =============================================================================\n# [patch.\"https://github.com/voidzero-dev/vite-task.git\"]\n# fspy = { path = \"../vite-task/crates/fspy\" }\n# vite_glob = { path = \"../vite-task/crates/vite_glob\" }\n# vite_path = { path = \"../vite-task/crates/vite_path\" }\n# vite_str = { path = \"../vite-task/crates/vite_str\" }\n# vite_task = { path = \"../vite-task/crates/vite_task\" }\n# vite_workspace = { path = \"../vite-task/crates/vite_workspace\" }\n\n[profile.dev]\n# Disabling debug info speeds up local and CI builds,\n# and we don't rely on it for debugging that much.\ndebug = false\n\n[profile.release]\n# Configurations explicitly listed here for clarity.\n# Using the best options for performance.\nopt-level = 3\nlto = \"fat\"\ncodegen-units = 1\nstrip = \"symbols\" # set to `false` for debug information\ndebug = false # set to `true` for debug information\npanic = \"abort\" # Let it crash and force ourselves to write safe Rust.\n\n# The trampoline binary is copied per shim tool (~5-10 copies), so optimize for\n# size instead of speed. This reduces it from ~200KB to ~100KB on Windows.\n[profile.release.package.vite_trampoline]\nopt-level = \"z\"\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2026-present, VoidZero Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"/logo-dark.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"/logo.svg\">\n  <img alt=\"Vite+\" src=\"/logo.svg\">\n</picture>\n\n**The Unified Toolchain for the Web**\n_runtime and package management, create, dev, check, test, build, pack, and monorepo task caching in a single dependency_\n\n---\n\nVite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows:\n\n- **`vp env`:** Manage Node.js globally and per project\n- **`vp install`:** Install dependencies with automatic package manager detection\n- **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR\n- **`vp check`:** Run formatting, linting, and type checks in one command\n- **`vp test`:** Run tests through bundled Vitest\n- **`vp build`:** Build applications for production with Vite + Rolldown\n- **`vp run`:** Execute monorepo tasks with caching and dependency-aware scheduling\n- **`vp pack`:** Build libraries for npm publishing or standalone app binaries\n- **`vp create` / `vp migrate`:** Scaffold new projects and migrate existing ones\n\nAll of this is configured from your project root and works across Vite's framework ecosystem.\nVite+ is fully open-source under the MIT license.\n\n## Getting Started\n\nInstall Vite+ globally as `vp`:\n\nFor Linux or macOS:\n\n```bash\ncurl -fsSL https://vite.plus | bash\n```\n\nFor Windows:\n\n```bash\nirm https://viteplus.dev/install.ps1 | iex\n```\n\n`vp` handles the full development lifecycle such as package management, development servers, linting, formatting, testing and building for production.\n\n## Configuring Vite+\n\nVite+ can be configured using a single `vite.config.ts` at the root of your project:\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  // Standard Vite configuration for dev/build/preview.\n  plugins: [],\n\n  // Vitest configuration.\n  test: {\n    include: ['src/**/*.test.ts'],\n  },\n\n  // Oxlint configuration.\n  lint: {\n    ignorePatterns: ['dist/**'],\n  },\n\n  // Oxfmt configuration.\n  fmt: {\n    semi: true,\n    singleQuote: true,\n  },\n\n  // Vite Task configuration.\n  run: {\n    tasks: {\n      'generate:icons': {\n        command: 'node scripts/generate-icons.js',\n        envs: ['ICON_THEME'],\n      },\n    },\n  },\n\n  // `vp staged` configuration.\n  staged: {\n    '*': 'vp check --fix',\n  },\n});\n```\n\nThis lets you keep the configuration for your development server, build, test, lint, format, task runner, and staged-file workflow in one place with type-safe config and shared defaults.\n\nUse `vp migrate` to migrate to Vite+. It merges tool-specific config files such as `.oxlintrc*`, `.oxfmtrc*`, and lint-staged config into `vite.config.ts`.\n\n### CLI Workflows (`vp help`)\n\n#### Start\n\n- **create** - Create a new project from a template\n- **migrate** - Migrate an existing project to Vite+\n- **config** - Configure hooks and agent integration\n- **staged** - Run linters on staged files\n- **install** (`i`) - Install dependencies\n- **env** - Manage Node.js versions\n\n#### Develop\n\n- **dev** - Run the development server\n- **check** - Run format, lint, and type checks\n- **lint** - Lint code\n- **fmt** - Format code\n- **test** - Run tests\n\n#### Execute\n\n- **run** - Run monorepo tasks\n- **exec** - Execute a command from local `node_modules/.bin`\n- **dlx** - Execute a package binary without installing it as a dependency\n- **cache** - Manage the task cache\n\n#### Build\n\n- **build** - Build for production\n- **pack** - Build libraries\n- **preview** - Preview production build\n\n#### Manage Dependencies\n\nVite+ automatically wraps your package manager (pnpm, npm, or Yarn) based on `packageManager` and lockfiles:\n\n- **add** - Add packages to dependencies\n- **remove** (`rm`, `un`, `uninstall`) - Remove packages from dependencies\n- **update** (`up`) - Update packages to latest versions\n- **dedupe** - Deduplicate dependencies\n- **outdated** - Check outdated packages\n- **list** (`ls`) - List installed packages\n- **why** (`explain`) - Show why a package is installed\n- **info** (`view`, `show`) - View package metadata from the registry\n- **link** (`ln`) / **unlink** - Manage local package links\n- **pm** - Forward a command to the package manager\n\n#### Maintain\n\n- **upgrade** - Update `vp` itself to the latest version\n- **implode** - Remove `vp` and all related data\n\n### Scaffolding your first Vite+ project\n\nUse `vp create` to create a new project:\n\n```bash\nvp create\n```\n\nYou can run `vp create` inside of a project to add new apps or libraries to your project.\n\n### Migrating an existing project\n\nYou can migrate an existing project to Vite+:\n\n```bash\nvp migrate\n```\n\n### GitHub Actions\n\nUse the official [`setup-vp`](https://github.com/voidzero-dev/setup-vp) action to install Vite+ in GitHub Actions:\n\n```yaml\n- uses: voidzero-dev/setup-vp@v1\n  with:\n    node-version: '22'\n    cache: true\n```\n\n#### Manual Installation & Migration\n\nIf you are manually migrating a project to Vite+, install these dev dependencies first:\n\n```bash\nnpm install -D vite-plus @voidzero-dev/vite-plus-core@latest\n```\n\nYou need to add overrides to your package manager for `vite` and `vitest` so that other packages depending on Vite and Vitest will use the Vite+ versions:\n\n```json\n\"overrides\": {\n  \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n  \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n}\n```\n\nIf you are using `pnpm`, add this to your `pnpm-workspace.yaml`:\n\n```yaml\noverrides:\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n```\n\nOr, if you are using Yarn:\n\n```json\n\"resolutions\": {\n  \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n  \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n}\n```\n\n## Sponsors\n\nThanks to [namespace.so](https://namespace.so) for powering our CI/CD pipelines with fast, free macOS and Linux runners.\n"
  },
  {
    "path": "bench/.gitignore",
    "content": "fixtures/monorepo"
  },
  {
    "path": "bench/Cargo.toml",
    "content": "[package]\nname = \"vite-plus-benches\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dev-dependencies]\nanyhow = { workspace = true }\nasync-trait = { workspace = true }\ncriterion = { workspace = true }\nrustc-hash = { workspace = true }\ntokio = { workspace = true, features = [\"rt\"] }\nvite_path = { workspace = true }\nvite_str = { workspace = true }\nvite_task = { workspace = true }\n\n[[bench]]\nname = \"workspace_load\"\nharness = false\n"
  },
  {
    "path": "bench/benches/workspace_load.rs",
    "content": "use std::{ffi::OsStr, hint::black_box, path::PathBuf, sync::Arc};\n\nuse criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};\nuse rustc_hash::FxHashMap;\nuse tokio::runtime::Runtime;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_str::Str;\nuse vite_task::{\n    CommandHandler, HandledCommand, Session, SessionConfig, plan_request::ScriptCommand,\n};\n\n/// A no-op command handler for benchmarking purposes.\n#[derive(Debug, Default)]\nstruct NoOpCommandHandler;\n\n#[async_trait::async_trait(?Send)]\nimpl CommandHandler for NoOpCommandHandler {\n    async fn handle_command(\n        &mut self,\n        _command: &mut ScriptCommand,\n    ) -> anyhow::Result<HandledCommand> {\n        Ok(HandledCommand::Verbatim)\n    }\n}\n\n/// A no-op user config loader for benchmarking.\n#[derive(Debug, Default)]\nstruct NoOpUserConfigLoader;\n\n#[async_trait::async_trait(?Send)]\nimpl vite_task::loader::UserConfigLoader for NoOpUserConfigLoader {\n    async fn load_user_config_file(\n        &self,\n        _package_path: &AbsolutePath,\n    ) -> anyhow::Result<Option<vite_task::config::UserRunConfig>> {\n        Ok(None)\n    }\n}\n\n/// Owned session callbacks for benchmarking.\n#[derive(Default)]\nstruct BenchSessionConfig {\n    command_handler: NoOpCommandHandler,\n    user_config_loader: NoOpUserConfigLoader,\n}\n\nimpl BenchSessionConfig {\n    fn as_callbacks(&mut self) -> SessionConfig<'_> {\n        SessionConfig {\n            command_handler: &mut self.command_handler,\n            user_config_loader: &mut self.user_config_loader,\n            program_name: Str::from(\"vp\"),\n        }\n    }\n}\n\nfn bench_workspace_load(c: &mut Criterion) {\n    let fixture_path = AbsolutePathBuf::new(PathBuf::from(env!(\"CARGO_MANIFEST_DIR\")))\n        .unwrap()\n        .join(\"fixtures\")\n        .join(\"monorepo\");\n\n    let runtime = Runtime::new().unwrap();\n\n    // Session::ensure_task_graph_loaded benchmark\n    let mut session_group = c.benchmark_group(\"session_task_graph_load\");\n    session_group.measurement_time(std::time::Duration::from_secs(10));\n\n    session_group.bench_function(\"ensure_task_graph_loaded\", |b| {\n        b.iter(|| {\n            runtime.block_on(async {\n                let mut owned_callbacks = BenchSessionConfig::default();\n                let envs: FxHashMap<Arc<OsStr>, Arc<OsStr>> = FxHashMap::default();\n                let mut session = Session::init_with(\n                    envs,\n                    fixture_path.clone().into(),\n                    owned_callbacks.as_callbacks(),\n                )\n                .expect(\"Failed to create session\");\n                black_box(\n                    session.ensure_task_graph_loaded().await.expect(\"Failed to load task graph\"),\n                );\n            });\n        });\n    });\n\n    session_group.bench_with_input(BenchmarkId::new(\"packages\", 100), &fixture_path, |b, path| {\n        b.iter(|| {\n            runtime.block_on(async {\n                let mut owned_callbacks = BenchSessionConfig::default();\n                let envs: FxHashMap<Arc<OsStr>, Arc<OsStr>> = FxHashMap::default();\n                let mut session =\n                    Session::init_with(envs, path.clone().into(), owned_callbacks.as_callbacks())\n                        .expect(\"Failed to create session\");\n                black_box(\n                    session.ensure_task_graph_loaded().await.expect(\"Failed to load task graph\"),\n                );\n            });\n        });\n    });\n\n    session_group.finish();\n}\n\ncriterion_group!(benches, bench_workspace_load);\ncriterion_main!(benches);\n"
  },
  {
    "path": "bench/fixtures/monorepo/package.json",
    "content": "{\n  \"name\": \"monorepo-benchmark\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"scripts\": {\n    \"build:all\": \"vite-plus run build\",\n    \"test:all\": \"vite-plus run test\",\n    \"lint:all\": \"vite-plus run lint\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"*\"\n  }\n}"
  },
  {
    "path": "bench/fixtures/monorepo/pnpm-workspace.yaml",
    "content": "packages:\n  - 'packages/*'\n"
  },
  {
    "path": "bench/fixtures/monorepo/vite-plus.json",
    "content": "{\n  \"tasks\": {\n    \"build\": {\n      \"cache\": true,\n      \"parallel\": true\n    },\n    \"test\": {\n      \"cache\": true,\n      \"parallel\": true\n    },\n    \"lint\": {\n      \"cache\": false,\n      \"parallel\": true\n    }\n  }\n}"
  },
  {
    "path": "bench/generate-monorepo.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\ninterface Package {\n  name: string;\n  dependencies: string[];\n  scripts: Record<string, string>;\n  hasVitePlusConfig: boolean;\n}\n\nconst __dirname = path.join(fileURLToPath(import.meta.url), '..');\n\nclass MonorepoGenerator {\n  private packages: Map<string, Package> = new Map();\n  private readonly PACKAGE_COUNT = 1000;\n  private readonly MAX_DEPS_PER_PACKAGE = 8;\n  private readonly MIN_DEPS_PER_PACKAGE = 2;\n  private readonly SCRIPT_NAMES = ['build', 'test', 'lint', 'dev', 'start', 'prepare', 'compile'];\n  private readonly CATEGORIES = ['core', 'util', 'feature', 'service', 'app'];\n\n  constructor(private rootDir: string) {}\n\n  private getRandomInt(min: number, max: number): number {\n    return Math.floor(Math.random() * (max - min + 1)) + min;\n  }\n\n  private getRandomElement<T>(arr: T[]): T {\n    return arr[Math.floor(Math.random() * arr.length)];\n  }\n\n  private generatePackageName(index: number): string {\n    const category = this.getRandomElement(this.CATEGORIES);\n    const paddedIndex = index.toString().padStart(2, '0');\n    return `${category}-${paddedIndex}`;\n  }\n\n  private generateScriptCommand(scriptName: string, packageName: string): string {\n    const commands = [\n      `echo \"Running ${scriptName} for ${packageName}\"`,\n      `node scripts/${scriptName}.js`,\n      `tsc --build`,\n      `webpack build`,\n      `rollup -c`,\n      `esbuild src/index.js --bundle`,\n      `npm run pre${scriptName}`,\n      `node tasks/${scriptName}`,\n    ];\n\n    // Generate command with 0-3 && concatenations\n    const numCommands = this.getRandomInt(1, 4);\n    const selectedCommands: string[] = [];\n\n    for (let i = 0; i < numCommands; i++) {\n      selectedCommands.push(this.getRandomElement(commands));\n    }\n\n    return selectedCommands.join(' && ');\n  }\n\n  private generateScripts(packageName: string): Record<string, string> {\n    const scripts: Record<string, string> = {};\n\n    // Each package has 2-3 scripts\n    const numScripts = this.getRandomInt(2, 3);\n    const selectedScripts = new Set<string>();\n\n    while (selectedScripts.size < numScripts) {\n      selectedScripts.add(this.getRandomElement(this.SCRIPT_NAMES));\n    }\n\n    for (const scriptName of selectedScripts) {\n      scripts[scriptName] = this.generateScriptCommand(scriptName, packageName);\n    }\n\n    return scripts;\n  }\n\n  private selectDependencies(currentIndex: number, availablePackages: string[]): string[] {\n    const numDeps = this.getRandomInt(this.MIN_DEPS_PER_PACKAGE, this.MAX_DEPS_PER_PACKAGE);\n    const dependencies = new Set<string>();\n\n    // Create a complex graph by selecting dependencies from different layers\n    // Prefer packages with lower indices (creates deeper dependency chains)\n    const eligiblePackages = availablePackages.filter((pkg) => {\n      const pkgIndex = parseInt(pkg.split('-')[1]);\n      return pkgIndex < currentIndex;\n    });\n\n    if (eligiblePackages.length === 0) {\n      return [];\n    }\n\n    while (dependencies.size < numDeps && dependencies.size < eligiblePackages.length) {\n      const dep = this.getRandomElement(eligiblePackages);\n      dependencies.add(dep);\n    }\n\n    // Add some cross-category dependencies for complexity\n    if (Math.random() > 0.3) {\n      const crossCategoryDeps = availablePackages.filter((pkg) => {\n        const category = pkg.split('-')[0];\n        return category !== currentIndex.toString().split('-')[0];\n      });\n\n      if (crossCategoryDeps.length > 0) {\n        dependencies.add(this.getRandomElement(crossCategoryDeps));\n      }\n    }\n\n    return Array.from(dependencies);\n  }\n\n  private generatePackages(): void {\n    // First, create all package names\n    const allPackageNames: string[] = [];\n    for (let i = 0; i < this.PACKAGE_COUNT; i++) {\n      allPackageNames.push(this.generatePackageName(i));\n    }\n\n    // Generate packages with dependencies\n    for (let i = 0; i < this.PACKAGE_COUNT; i++) {\n      const packageName = allPackageNames[i];\n      const scripts = this.generateScripts(packageName);\n\n      // 70% chance to have vite-plus.json config\n      const hasVitePlusConfig = Math.random() > 0.3;\n\n      // Select dependencies from packages created before this one\n      const dependencies = i === 0 ? [] : this.selectDependencies(i, allPackageNames.slice(0, i));\n\n      this.packages.set(packageName, {\n        name: packageName,\n        dependencies,\n        scripts,\n        hasVitePlusConfig,\n      });\n    }\n\n    // Ensure complex transitive dependencies for script resolution testing\n    this.addTransitiveScriptDependencies();\n  }\n\n  private addTransitiveScriptDependencies(): void {\n    // Create specific patterns for testing transitive script dependencies\n    const packagesArray = Array.from(this.packages.entries());\n\n    for (let i = 0; i < 50; i++) {\n      const [nameA, pkgA] = this.getRandomElement(packagesArray);\n      const [nameB, pkgB] = this.getRandomElement(packagesArray);\n      const [nameC, pkgC] = this.getRandomElement(packagesArray);\n\n      if (nameA !== nameB && nameB !== nameC && nameA !== nameC) {\n        // Setup: A depends on B, B depends on C\n        if (!pkgA.dependencies.includes(nameB)) {\n          pkgA.dependencies.push(nameB);\n        }\n        if (!pkgB.dependencies.includes(nameC)) {\n          pkgB.dependencies.push(nameC);\n        }\n\n        // Create the scenario: A has build, B doesn't, C has build\n        const scriptName = this.getRandomElement(this.SCRIPT_NAMES);\n        pkgA.scripts[scriptName] = this.generateScriptCommand(scriptName, nameA);\n        delete pkgB.scripts[scriptName]; // B doesn't have the script\n        pkgC.scripts[scriptName] = this.generateScriptCommand(scriptName, nameC);\n      }\n    }\n  }\n\n  private writePackage(pkg: Package): void {\n    const packageDir = path.join(this.rootDir, 'packages', pkg.name);\n\n    // Create directory structure\n    fs.mkdirSync(packageDir, { recursive: true });\n    fs.mkdirSync(path.join(packageDir, 'src'), { recursive: true });\n\n    // Write package.json\n    const packageJson = {\n      name: `@monorepo/${pkg.name}`,\n      version: '1.0.0',\n      main: 'src/index.js',\n      scripts: pkg.scripts,\n      dependencies: pkg.dependencies.reduce(\n        (deps, dep) => {\n          deps[`@monorepo/${dep}`] = 'workspace:*';\n          return deps;\n        },\n        {} as Record<string, string>,\n      ),\n    };\n\n    fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify(packageJson, null, 2));\n\n    // Write source file\n    const indexContent = `// ${pkg.name} module\nexport function ${pkg.name.replace('-', '_')}() {\n  console.log('Executing ${pkg.name}');\n${pkg.dependencies.map((dep) => `  require('@monorepo/${dep}');`).join('\\n')}\n}\n\nmodule.exports = { ${pkg.name.replace('-', '_')} };\n`;\n\n    fs.writeFileSync(path.join(packageDir, 'src', 'index.js'), indexContent);\n\n    // Write vite-plus.json if needed\n    if (pkg.hasVitePlusConfig) {\n      const vitePlusConfig = {\n        extends: '../../vite-plus.json',\n        tasks: {\n          build: {\n            cache: true,\n            env: {\n              NODE_ENV: 'production',\n            },\n          },\n        },\n      };\n\n      fs.writeFileSync(\n        path.join(packageDir, 'vite-plus.json'),\n        JSON.stringify(vitePlusConfig, null, 2),\n      );\n    }\n  }\n\n  public generate(): void {\n    console.log('Generating monorepo structure…');\n\n    // Clean and create root directory\n    if (fs.existsSync(this.rootDir)) {\n      fs.rmSync(this.rootDir, { recursive: true, force: true });\n    }\n    fs.mkdirSync(this.rootDir, { recursive: true });\n    fs.mkdirSync(path.join(this.rootDir, 'packages'), { recursive: true });\n\n    // Generate packages\n    this.generatePackages();\n\n    // Write all packages\n    let count = 0;\n    for (const [_, pkg] of this.packages) {\n      this.writePackage(pkg);\n      count++;\n      if (count % 100 === 0) {\n        console.log(`Generated ${count} packages…`);\n      }\n    }\n\n    // Write root package.json\n    const rootPackageJson = {\n      name: 'monorepo-benchmark',\n      version: '1.0.0',\n      private: true,\n      workspaces: ['packages/*'],\n      scripts: {\n        'build:all': 'vp run build',\n        'test:all': 'vp run test',\n        'lint:all': 'vp run lint',\n      },\n      devDependencies: {\n        'vite-plus': '*',\n      },\n    };\n\n    fs.writeFileSync(\n      path.join(this.rootDir, 'package.json'),\n      JSON.stringify(rootPackageJson, null, 2),\n    );\n\n    // Write pnpm-workspace.yaml for pnpm support\n    const pnpmWorkspace = `packages:\n  - 'packages/*'\n`;\n    fs.writeFileSync(path.join(this.rootDir, 'pnpm-workspace.yaml'), pnpmWorkspace);\n\n    // Write root vite-plus.json\n    const rootVitePlusConfig = {\n      tasks: {\n        build: {\n          cache: true,\n          parallel: true,\n        },\n        test: {\n          cache: true,\n          parallel: true,\n        },\n        lint: {\n          cache: false,\n          parallel: true,\n        },\n      },\n    };\n\n    fs.writeFileSync(\n      path.join(this.rootDir, 'vite-plus.json'),\n      JSON.stringify(rootVitePlusConfig, null, 2),\n    );\n\n    console.log(`Successfully generated monorepo with ${this.PACKAGE_COUNT} packages!`);\n    console.log(`Location: ${this.rootDir}`);\n\n    // Print some statistics\n    this.printStatistics();\n  }\n\n  private printStatistics(): void {\n    let totalDeps = 0;\n    let maxDeps = 0;\n    let packagesWithVitePlus = 0;\n    const scriptCounts = new Map<string, number>();\n\n    for (const [_, pkg] of this.packages) {\n      totalDeps += pkg.dependencies.length;\n      maxDeps = Math.max(maxDeps, pkg.dependencies.length);\n\n      if (pkg.hasVitePlusConfig) {\n        packagesWithVitePlus++;\n      }\n\n      for (const script of Object.keys(pkg.scripts)) {\n        scriptCounts.set(script, (scriptCounts.get(script) || 0) + 1);\n      }\n    }\n\n    console.log('\\nStatistics:');\n    console.log(`- Total packages: ${this.packages.size}`);\n    console.log(\n      `- Average dependencies per package: ${(totalDeps / this.packages.size).toFixed(2)}`,\n    );\n    console.log(`- Max dependencies in a package: ${maxDeps}`);\n    console.log(`- Packages with vite-plus.json: ${packagesWithVitePlus}`);\n    console.log('- Script distribution:');\n    for (const [script, count] of scriptCounts) {\n      console.log(`  - ${script}: ${count} packages`);\n    }\n  }\n}\n\n// Main execution\nconst outputDir = path.join(__dirname, 'fixtures', 'monorepo');\nconst generator = new MonorepoGenerator(outputDir);\ngenerator.generate();\n"
  },
  {
    "path": "bench/package.json",
    "content": "{\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "bench/tsconfig.json",
    "content": "{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"./dist\",\n    \"noEmit\": true,\n    \"erasableSyntaxOnly\": false\n  },\n  \"include\": [\"**/*.ts\"]\n}\n"
  },
  {
    "path": "crates/vite_command/Cargo.toml",
    "content": "[package]\nname = \"vite_command\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\nrust-version.workspace = true\n\n[dependencies]\nfspy = { workspace = true }\ntokio = { workspace = true }\ntracing = { workspace = true }\nvite_error = { workspace = true }\nvite_path = { workspace = true }\nwhich = { workspace = true, features = [\"tracing\"] }\n\n[target.'cfg(not(target_os = \"windows\"))'.dependencies]\nnix = { workspace = true }\n\n[dev-dependencies]\ntempfile = { workspace = true }\ntokio = { workspace = true, features = [\"macros\", \"test-util\"] }\n\n[lints]\nworkspace = true\n\n[lib]\ndoctest = false\n"
  },
  {
    "path": "crates/vite_command/src/lib.rs",
    "content": "use std::{\n    collections::HashMap,\n    ffi::OsStr,\n    process::{ExitStatus, Stdio},\n};\n\nuse fspy::AccessMode;\nuse tokio::process::Command;\nuse vite_error::Error;\nuse vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf};\n\n/// Result of running a command with fspy tracking.\n#[derive(Debug)]\npub struct FspyCommandResult {\n    /// The termination status of the command.\n    pub status: ExitStatus,\n    /// The path accesses of the command.\n    pub path_accesses: HashMap<RelativePathBuf, AccessMode>,\n}\n\n/// Resolve a binary name to a full path using the `which` crate.\n/// Handles PATHEXT (`.cmd`/`.bat`) resolution natively on Windows.\n///\n/// If `path_env` is `None`, searches the process's current `PATH`.\npub fn resolve_bin(\n    bin_name: &str,\n    path_env: Option<&OsStr>,\n    cwd: impl AsRef<AbsolutePath>,\n) -> Result<AbsolutePathBuf, Error> {\n    let current_path;\n    let path_env = match path_env {\n        Some(p) => p,\n        None => {\n            current_path = std::env::var_os(\"PATH\").unwrap_or_default();\n            &current_path\n        }\n    };\n    let path = which::which_in(bin_name, Some(path_env), cwd.as_ref())\n        .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?;\n    AbsolutePathBuf::new(path).ok_or_else(|| Error::CannotFindBinaryPath(bin_name.into()))\n}\n\n/// Build a `tokio::process::Command` for a pre-resolved binary path.\n/// Sets inherited stdio and `fix_stdio_streams` (Unix pre_exec).\n/// Callers can further customize (add args, envs, override stdio, etc.).\npub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command {\n    let mut cmd = Command::new(bin_path.as_path());\n    cmd.current_dir(cwd).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());\n\n    #[cfg(unix)]\n    unsafe {\n        cmd.pre_exec(|| {\n            fix_stdio_streams();\n            Ok(())\n        });\n    }\n\n    cmd\n}\n\n/// Build a `tokio::process::Command` for shell execution.\n/// Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows.\npub fn build_shell_command(shell_cmd: &str, cwd: &AbsolutePath) -> Command {\n    #[cfg(unix)]\n    let mut cmd = {\n        let mut cmd = Command::new(\"/bin/sh\");\n        cmd.arg(\"-c\").arg(shell_cmd);\n        cmd\n    };\n\n    #[cfg(windows)]\n    let mut cmd = {\n        let mut cmd = Command::new(\"cmd.exe\");\n        cmd.arg(\"/C\").arg(shell_cmd);\n        cmd\n    };\n\n    cmd.current_dir(cwd).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());\n\n    #[cfg(unix)]\n    unsafe {\n        cmd.pre_exec(|| {\n            fix_stdio_streams();\n            Ok(())\n        });\n    }\n\n    cmd\n}\n\n/// Run a command with the given bin name, arguments, environment variables, and current working directory.\n///\n/// # Arguments\n///\n/// * `bin_name`: The name of the binary to run.\n/// * `args`: The arguments to pass to the binary.\n/// * `envs`: The custom environment variables to set for the command, will be merged with the system environment variables.\n/// * `cwd`: The current working directory for the command.\n///\n/// # Returns\n///\n/// Returns the exit status of the command.\npub async fn run_command<I, S>(\n    bin_name: &str,\n    args: I,\n    envs: &HashMap<String, String>,\n    cwd: impl AsRef<AbsolutePath>,\n) -> Result<ExitStatus, Error>\nwhere\n    I: IntoIterator<Item = S>,\n    S: AsRef<OsStr>,\n{\n    let cwd = cwd.as_ref();\n    let paths = envs.get(\"PATH\");\n    let bin_path = resolve_bin(bin_name, paths.map(|p| OsStr::new(p.as_str())), cwd)?;\n    let mut cmd = build_command(&bin_path, cwd);\n    cmd.args(args).envs(envs);\n    let status = cmd.status().await?;\n    Ok(status)\n}\n\n/// Run a command with fspy tracking.\n///\n/// # Arguments\n///\n/// * `bin_name`: The name of the binary to run.\n/// * `args`: The arguments to pass to the binary.\n/// * `envs`: The custom environment variables to set for the command.\n/// * `cwd`: The current working directory for the command.\n///\n/// # Returns\n///\n/// Returns a FspyCommandResult containing the exit status and path accesses.\npub async fn run_command_with_fspy<I, S>(\n    bin_name: &str,\n    args: I,\n    envs: &HashMap<String, String>,\n    cwd: impl AsRef<AbsolutePath>,\n) -> Result<FspyCommandResult, Error>\nwhere\n    I: IntoIterator<Item = S>,\n    S: AsRef<OsStr>,\n{\n    let cwd = cwd.as_ref();\n    let mut cmd = fspy::Command::new(bin_name);\n    cmd.args(args)\n        // set system environment variables first\n        .envs(std::env::vars_os())\n        // then set custom environment variables\n        .envs(envs)\n        .current_dir(cwd)\n        .stdin(Stdio::inherit())\n        .stdout(Stdio::inherit())\n        .stderr(Stdio::inherit());\n\n    // fix stdio streams on unix\n    #[cfg(unix)]\n    unsafe {\n        cmd.pre_exec(|| {\n            fix_stdio_streams();\n            Ok(())\n        });\n    }\n\n    let child = cmd.spawn().await.map_err(|e| Error::Anyhow(e.into()))?;\n    let termination = child.wait_handle.await?;\n\n    let mut path_accesses = HashMap::<RelativePathBuf, AccessMode>::new();\n    for access in termination.path_accesses.iter() {\n        tracing::debug!(\"Path access: {:?}\", access);\n        let relative_path = access\n            .path\n            .strip_path_prefix(cwd, |strip_result| {\n                let Ok(stripped_path) = strip_result else {\n                    return None;\n                };\n                if stripped_path.as_os_str().is_empty() {\n                    return None;\n                }\n                tracing::debug!(\"stripped_path: {:?}\", stripped_path);\n                Some(RelativePathBuf::new(stripped_path).map_err(|err| {\n                    Error::InvalidRelativePath { path: stripped_path.into(), reason: err }\n                }))\n            })\n            .transpose()?;\n        let Some(relative_path) = relative_path else {\n            continue;\n        };\n        path_accesses\n            .entry(relative_path)\n            .and_modify(|mode| *mode |= access.mode)\n            .or_insert(access.mode);\n    }\n\n    Ok(FspyCommandResult { status: termination.status, path_accesses })\n}\n\n#[cfg(unix)]\npub fn fix_stdio_streams() {\n    // libuv may mark stdin/stdout/stderr as close-on-exec, which interferes with Rust's subprocess spawning.\n    // As a workaround, we clear the FD_CLOEXEC flag on these file descriptors to prevent them from being closed when spawning child processes.\n    //\n    // For details see https://github.com/libuv/libuv/issues/2062\n    // Fixed by reference from https://github.com/electron/electron/pull/15555\n\n    use std::os::fd::BorrowedFd;\n\n    use nix::{\n        fcntl::{FcntlArg, FdFlag, fcntl},\n        libc::{STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},\n    };\n\n    // Safe function to clear FD_CLOEXEC flag\n    fn clear_cloexec(fd: BorrowedFd<'_>) {\n        // Borrow RawFd as BorrowedFd to satisfy AsFd constraint\n        if let Ok(flags) = fcntl(fd, FcntlArg::F_GETFD) {\n            let mut fd_flags = FdFlag::from_bits_retain(flags);\n            if fd_flags.contains(FdFlag::FD_CLOEXEC) {\n                fd_flags.remove(FdFlag::FD_CLOEXEC);\n                // Ignore errors: some fd may be closed\n                let _ = fcntl(fd, FcntlArg::F_SETFD(fd_flags));\n            }\n        }\n    }\n\n    // Clear FD_CLOEXEC on stdin, stdout, stderr\n    clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) });\n    clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDOUT_FILENO) });\n    clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDERR_FILENO) });\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    mod run_command_tests {\n\n        use super::*;\n\n        #[tokio::test]\n        async fn test_run_command_and_find_binary_path() {\n            let temp_dir = create_temp_dir();\n            let temp_dir_path =\n                AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())\n                    .unwrap();\n            let envs = HashMap::from([(\n                \"PATH\".to_string(),\n                std::env::var_os(\"PATH\").unwrap_or_default().into_string().unwrap(),\n            )]);\n            let result = run_command(\"npm\", &[\"--version\"], &envs, &temp_dir_path).await;\n            assert!(result.is_ok(), \"Should run command successfully, but got error: {:?}\", result);\n        }\n\n        #[tokio::test]\n        async fn test_run_command_and_not_find_binary_path() {\n            let temp_dir = create_temp_dir();\n            let temp_dir_path =\n                AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())\n                    .unwrap();\n            let envs = HashMap::from([(\n                \"PATH\".to_string(),\n                std::env::var_os(\"PATH\").unwrap_or_default().into_string().unwrap(),\n            )]);\n            let result = run_command(\"npm-not-exists\", &[\"--version\"], &envs, &temp_dir_path).await;\n            assert!(result.is_err(), \"Should not find binary path, but got: {:?}\", result);\n            assert_eq!(\n                result.unwrap_err().to_string(),\n                \"Cannot find binary path for command 'npm-not-exists'\"\n            );\n        }\n    }\n\n    mod run_command_with_fspy_tests {\n        use super::*;\n\n        #[tokio::test]\n        async fn test_run_command_with_fspy() {\n            let temp_dir = create_temp_dir();\n            let temp_dir_path =\n                AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())\n                    .unwrap();\n            let envs = HashMap::from([(\n                \"PATH\".to_string(),\n                std::env::var_os(\"PATH\").unwrap_or_default().into_string().unwrap(),\n            )]);\n            let result =\n                run_command_with_fspy(\"node\", &[\"-p\", \"process.cwd()\"], &envs, &temp_dir_path)\n                    .await;\n            assert!(result.is_ok(), \"Should run command successfully, but got error: {:?}\", result);\n            let cmd_result = result.unwrap();\n            assert!(cmd_result.status.success());\n        }\n\n        #[tokio::test]\n        async fn test_run_command_with_fspy_and_capture_path_accesses_write_file() {\n            let temp_dir = create_temp_dir();\n            let temp_dir_path =\n                AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())\n                    .unwrap();\n            let envs = HashMap::from([(\n                \"PATH\".to_string(),\n                std::env::var_os(\"PATH\").unwrap_or_default().into_string().unwrap(),\n            )]);\n            let result = run_command_with_fspy(\n                \"node\",\n                &[\"-p\", \"fs.writeFileSync(path.join(process.cwd(), 'package.json'), '{}');'done'\"],\n                &envs,\n                &temp_dir_path,\n            )\n            .await;\n            assert!(result.is_ok(), \"Should run command successfully, but got error: {:?}\", result);\n            let cmd_result = result.unwrap();\n            assert!(cmd_result.status.success());\n            eprintln!(\"cmd_result: {:?}\", cmd_result);\n            // Verify package.json is in path accesses with WRITE mode.\n            // Note: We don't assert exact count of path accesses because `node` may be a shim\n            // from tool version managers (volta, mise, fnm, etc.) that read additional config\n            // files (e.g., .tool-versions, .mise.toml, .nvmrc) to determine which Node version\n            // to use.\n            let path_access = cmd_result\n                .path_accesses\n                .get(&RelativePathBuf::new(\"package.json\").unwrap())\n                .expect(\"package.json should be in path accesses\");\n            assert!(path_access.contains(AccessMode::WRITE));\n            // Note: We don't assert !READ because writeFileSync may trigger reads\n            // depending on Node.js internals and OS filesystem behavior\n        }\n\n        #[tokio::test]\n        async fn test_run_command_with_fspy_and_capture_path_accesses_write_and_read_file() {\n            let temp_dir = create_temp_dir();\n            let temp_dir_path =\n                AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())\n                    .unwrap();\n            let envs = HashMap::from([(\n                \"PATH\".to_string(),\n                std::env::var_os(\"PATH\").unwrap_or_default().into_string().unwrap(),\n            )]);\n            let result = run_command_with_fspy(\n                \"node\",\n                &[\"-p\", \"fs.writeFileSync(path.join(process.cwd(), 'package.json'), '{}'); fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'); 'done'\"],\n                &envs,\n                &temp_dir_path,\n            )\n            .await;\n            assert!(result.is_ok(), \"Should run command successfully, but got error: {:?}\", result);\n            let cmd_result = result.unwrap();\n            assert!(cmd_result.status.success());\n            eprintln!(\"cmd_result: {:?}\", cmd_result);\n            // Verify package.json is in path accesses with WRITE and READ modes.\n            // Note: We don't assert exact count of path accesses because `node` may be a shim\n            // from tool version managers (volta, mise, fnm, etc.) that read additional config\n            // files (e.g., .tool-versions, .mise.toml, .nvmrc) to determine which Node version\n            // to use.\n            let path_access = cmd_result\n                .path_accesses\n                .get(&RelativePathBuf::new(\"package.json\").unwrap())\n                .expect(\"package.json should be in path accesses\");\n            assert!(path_access.contains(AccessMode::WRITE));\n            assert!(path_access.contains(AccessMode::READ));\n        }\n\n        #[tokio::test]\n        async fn test_run_command_with_fspy_and_not_find_binary_path() {\n            let temp_dir = create_temp_dir();\n            let temp_dir_path =\n                AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf())\n                    .unwrap();\n            let envs = HashMap::from([(\n                \"PATH\".to_string(),\n                std::env::var_os(\"PATH\").unwrap_or_default().into_string().unwrap(),\n            )]);\n            let result =\n                run_command_with_fspy(\"npm-not-exists\", &[\"--version\"], &envs, &temp_dir_path)\n                    .await;\n            assert!(result.is_err(), \"Should not find binary path, but got: {:?}\", result);\n            assert!(\n                result\n                    .err()\n                    .unwrap()\n                    .to_string()\n                    .contains(\"could not resolve the full path of program '\\\"npm-not-exists\\\"'\")\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "crates/vite_error/Cargo.toml",
    "content": "[package]\nname = \"vite_error\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nanyhow = { workspace = true }\nast-grep-config = { workspace = true }\nbincode = { workspace = true }\nbstr = { workspace = true }\nignore = { workspace = true }\nnix = { workspace = true }\nrusqlite = { workspace = true }\nsemver = { workspace = true }\nserde_json = { workspace = true }\nserde_yml = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true }\nvite_path = { workspace = true }\nvite_str = { workspace = true }\nvite_workspace = { workspace = true }\nwax = { workspace = true }\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nreqwest = { workspace = true, features = [\"stream\", \"native-tls-vendored\", \"json\"] }\n\n[target.'cfg(not(target_os = \"windows\"))'.dependencies]\nreqwest = { workspace = true, features = [\"stream\", \"rustls-tls\", \"json\"] }\n\n[lib]\ntest = false\ndoctest = false\n"
  },
  {
    "path": "crates/vite_error/src/lib.rs",
    "content": "use std::{ffi::OsString, path::Path, sync::Arc};\n\nuse thiserror::Error;\nuse vite_path::{AbsolutePath, AbsolutePathBuf, relative::FromPathError};\nuse vite_str::Str;\n\n#[derive(Error, Debug)]\npub enum Error {\n    #[error(transparent)]\n    Sqlite(#[from] rusqlite::Error),\n\n    #[error(transparent)]\n    BincodeEncode(#[from] bincode::error::EncodeError),\n\n    #[error(transparent)]\n    BincodeDecode(#[from] bincode::error::DecodeError),\n\n    #[error(\"Unrecognized db version: {0}\")]\n    UnrecognizedDbVersion(u32),\n\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    #[error(\"IO error: {err} at {path:?}\")]\n    IoWithPath { err: std::io::Error, path: Arc<AbsolutePath> },\n\n    #[error(transparent)]\n    JoinPathsError(#[from] std::env::JoinPathsError),\n\n    #[cfg(unix)]\n    #[error(transparent)]\n    Nix(#[from] nix::Error),\n\n    #[error(transparent)]\n    Serde(#[from] serde_json::Error),\n\n    #[error(\"Env value is not valid unicode: {key} = {value:?}\")]\n    EnvValueIsNotValidUnicode { key: Str, value: OsString },\n\n    #[cfg(unix)]\n    #[error(\"Unsupported file type: {0:?}\")]\n    UnsupportedFileType(nix::dir::Type),\n\n    #[cfg(windows)]\n    #[error(\"Unsupported file type: {0:?}\")]\n    UnsupportedFileType(std::fs::FileType),\n\n    #[error(transparent)]\n    Utf8Error(#[from] bstr::Utf8Error),\n\n    #[error(transparent)]\n    WaxBuild(#[from] wax::BuildError),\n\n    #[error(transparent)]\n    WaxWalk(#[from] wax::WalkError),\n\n    #[error(transparent)]\n    IgnoreError(#[from] ignore::Error),\n\n    #[error(transparent)]\n    SerdeYml(#[from] serde_yml::Error),\n\n    #[error(transparent)]\n    WorkspaceError(#[from] vite_workspace::Error),\n\n    #[error(\"Lint failed, reason: {reason}\")]\n    LintFailed { status: Str, reason: Str },\n\n    #[error(\"Fmt failed\")]\n    FmtFailed { status: Str, reason: Str },\n\n    #[error(\"Vite failed\")]\n    Vite { status: Str, reason: Str },\n\n    #[error(\"Test failed\")]\n    TestFailed { status: Str, reason: Str },\n\n    #[error(\"Lib failed\")]\n    LibFailed { status: Str, reason: Str },\n\n    #[error(\"Doc failed, reason: {reason}\")]\n    DocFailed { status: Str, reason: Str },\n\n    #[error(\"Resolve universal vite config failed\")]\n    ResolveUniversalViteConfigFailed { status: Str, reason: Str },\n\n    #[error(\"The path ({path:?}) is not a valid relative path because: {reason}\")]\n    InvalidRelativePath { path: Box<Path>, reason: FromPathError },\n\n    #[error(\"Unsupported package manager: {0}\")]\n    UnsupportedPackageManager(Str),\n\n    #[error(\"Unrecognized any package manager, please specify the package manager\")]\n    UnrecognizedPackageManager,\n\n    #[error(\n        \"Package manager {name}@{version} in {package_json_path:?} is invalid, expected format: 'package-manager-name@major.minor.patch'\"\n    )]\n    PackageManagerVersionInvalid { name: Str, version: Str, package_json_path: AbsolutePathBuf },\n\n    #[error(\"Package manager {name}@{version} not found on {url}\")]\n    PackageManagerVersionNotFound { name: Str, version: Str, url: Str },\n\n    #[error(transparent)]\n    Semver(#[from] semver::Error),\n\n    #[error(transparent)]\n    Reqwest(#[from] reqwest::Error),\n\n    #[error(transparent)]\n    JoinError(#[from] tokio::task::JoinError),\n\n    #[error(\"User cancelled by Ctrl+C\")]\n    UserCancelled,\n\n    #[error(\"Hash mismatch: expected {expected}, got {actual}\")]\n    HashMismatch { expected: Str, actual: Str },\n\n    #[error(\"Invalid hash format: {0}\")]\n    InvalidHashFormat(Str),\n\n    #[error(\"Unsupported hash algorithm: {0}\")]\n    UnsupportedHashAlgorithm(Str),\n\n    #[error(\"Cannot find binary path for command '{0}'\")]\n    CannotFindBinaryPath(Str),\n\n    #[error(\"Invalid argument: {0}\")]\n    InvalidArgument(Str),\n\n    #[error(transparent)]\n    AstGrepConfigError(#[from] ast_grep_config::RuleConfigError),\n\n    #[error(transparent)]\n    Anyhow(#[from] anyhow::Error),\n}\n"
  },
  {
    "path": "crates/vite_global_cli/Cargo.toml",
    "content": "[package]\nname = \"vite_global_cli\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[[bin]]\nname = \"vp\"\npath = \"src/main.rs\"\n\n[dependencies]\nbase64-simd = { workspace = true }\nchrono = { workspace = true }\nclap = { workspace = true, features = [\"derive\"] }\nclap_complete = { workspace = true }\ndirectories = { workspace = true }\nflate2 = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nnode-semver = { workspace = true }\nsha2 = { workspace = true }\ntar = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"full\"] }\ntracing = { workspace = true }\nowo-colors = { workspace = true }\noxc_resolver = { workspace = true }\ncrossterm = { workspace = true }\nvite_error = { workspace = true }\nvite_install = { workspace = true }\nvite_js_runtime = { workspace = true }\nvite_path = { workspace = true }\nvite_command = { workspace = true }\nvite_shared = { workspace = true }\nvite_str = { workspace = true }\nvite_workspace = { workspace = true }\n\n[target.'cfg(windows)'.dependencies]\njunction = { workspace = true }\n\n[dev-dependencies]\nserial_test = { workspace = true }\ntempfile = { workspace = true }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/vite_global_cli/src/cli.rs",
    "content": "//! CLI argument parsing and command routing.\n//!\n//! This module defines the CLI structure using clap and routes commands\n//! to their appropriate handlers.\n\nuse std::process::ExitStatus;\n\nuse clap::{CommandFactory, FromArgMatches, Parser, Subcommand};\nuse vite_install::commands::{\n    add::SaveDependencyType, install::InstallCommandOptions, outdated::Format,\n};\nuse vite_path::AbsolutePathBuf;\n\nuse crate::{\n    commands::{\n        self, AddCommand, DedupeCommand, DlxCommand, InstallCommand, LinkCommand, OutdatedCommand,\n        RemoveCommand, UnlinkCommand, UpdateCommand, WhyCommand,\n    },\n    error::Error,\n    help,\n};\n\n#[derive(Clone, Copy, Debug)]\npub struct RenderOptions {\n    pub show_header: bool,\n}\n\nimpl Default for RenderOptions {\n    fn default() -> Self {\n        Self { show_header: true }\n    }\n}\n\n/// Vite+ Global CLI\n#[derive(Parser, Debug)]\n#[clap(\n    name = \"vp\",\n    bin_name = \"vp\",\n    author,\n    about = \"Vite+ - A next-generation build tool\",\n    long_about = None\n)]\n#[command(disable_help_subcommand = true, disable_version_flag = true)]\npub struct Args {\n    /// Print version\n    #[arg(short = 'V', long = \"version\")]\n    pub version: bool,\n\n    #[clap(subcommand)]\n    pub command: Option<Commands>,\n}\n\n/// Available commands\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // =========================================================================\n    // Category A: Package Manager Commands\n    // =========================================================================\n    /// Install all dependencies, or add packages if package names are provided\n    #[command(visible_alias = \"i\")]\n    Install {\n        /// Do not install devDependencies\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only install devDependencies (install) / Save to devDependencies (add)\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Do not install optionalDependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Fail if lockfile needs to be updated (CI mode)\n        #[arg(long, overrides_with = \"no_frozen_lockfile\")]\n        frozen_lockfile: bool,\n\n        /// Allow lockfile updates (opposite of --frozen-lockfile)\n        #[arg(long, overrides_with = \"frozen_lockfile\")]\n        no_frozen_lockfile: bool,\n\n        /// Only update lockfile, don't install\n        #[arg(long)]\n        lockfile_only: bool,\n\n        /// Use cached packages when available\n        #[arg(long)]\n        prefer_offline: bool,\n\n        /// Only use packages already in cache\n        #[arg(long)]\n        offline: bool,\n\n        /// Force reinstall all dependencies\n        #[arg(short = 'f', long)]\n        force: bool,\n\n        /// Do not run lifecycle scripts\n        #[arg(long)]\n        ignore_scripts: bool,\n\n        /// Don't read or generate lockfile\n        #[arg(long)]\n        no_lockfile: bool,\n\n        /// Fix broken lockfile entries (pnpm and yarn@2+ only)\n        #[arg(long)]\n        fix_lockfile: bool,\n\n        /// Create flat `node_modules` (pnpm only)\n        #[arg(long)]\n        shamefully_hoist: bool,\n\n        /// Re-run resolution for peer dependency analysis (pnpm only)\n        #[arg(long)]\n        resolution_only: bool,\n\n        /// Suppress output (silent mode)\n        #[arg(long)]\n        silent: bool,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Install in workspace root only\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Save exact version (only when adding packages)\n        #[arg(short = 'E', long)]\n        save_exact: bool,\n\n        /// Save to peerDependencies (only when adding packages)\n        #[arg(long)]\n        save_peer: bool,\n\n        /// Save to optionalDependencies (only when adding packages)\n        #[arg(short = 'O', long)]\n        save_optional: bool,\n\n        /// Save the new dependency to the default catalog (only when adding packages)\n        #[arg(long)]\n        save_catalog: bool,\n\n        /// Install globally (only when adding packages)\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Node.js version to use for global installation (only with -g)\n        #[arg(long, requires = \"global\")]\n        node: Option<String>,\n\n        /// Packages to add (if provided, acts as `vp add`)\n        #[arg(required = false)]\n        packages: Option<Vec<String>>,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Add packages to dependencies\n    Add {\n        /// Save to `dependencies` (default)\n        #[arg(short = 'P', long)]\n        save_prod: bool,\n\n        /// Save to `devDependencies`\n        #[arg(short = 'D', long)]\n        save_dev: bool,\n\n        /// Save to `peerDependencies` and `devDependencies`\n        #[arg(long)]\n        save_peer: bool,\n\n        /// Save to `optionalDependencies`\n        #[arg(short = 'O', long)]\n        save_optional: bool,\n\n        /// Save exact version rather than semver range\n        #[arg(short = 'E', long)]\n        save_exact: bool,\n\n        /// Save the new dependency to the specified catalog name\n        #[arg(long, value_name = \"CATALOG_NAME\")]\n        save_catalog_name: Option<String>,\n\n        /// Save the new dependency to the default catalog\n        #[arg(long)]\n        save_catalog: bool,\n\n        /// A list of package names allowed to run postinstall\n        #[arg(long, value_name = \"NAMES\")]\n        allow_build: Option<String>,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Add to workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Only add if package exists in workspace (pnpm-specific)\n        #[arg(long)]\n        workspace: bool,\n\n        /// Install globally\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Node.js version to use for global installation (only with -g)\n        #[arg(long, requires = \"global\")]\n        node: Option<String>,\n\n        /// Packages to add\n        #[arg(required = true)]\n        packages: Vec<String>,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Remove packages from dependencies\n    #[command(visible_alias = \"rm\", visible_alias = \"un\", visible_alias = \"uninstall\")]\n    Remove {\n        /// Only remove from `devDependencies` (pnpm-specific)\n        #[arg(short = 'D', long)]\n        save_dev: bool,\n\n        /// Only remove from `optionalDependencies` (pnpm-specific)\n        #[arg(short = 'O', long)]\n        save_optional: bool,\n\n        /// Only remove from `dependencies` (pnpm-specific)\n        #[arg(short = 'P', long)]\n        save_prod: bool,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Remove from workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Remove recursively from all workspace packages\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Remove global packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Preview what would be removed without actually removing (only with -g)\n        #[arg(long, requires = \"global\")]\n        dry_run: bool,\n\n        /// Packages to remove\n        #[arg(required = true)]\n        packages: Vec<String>,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Update packages to their latest versions\n    #[command(visible_alias = \"up\")]\n    Update {\n        /// Update to latest version (ignore semver range)\n        #[arg(short = 'L', long)]\n        latest: bool,\n\n        /// Update global packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Update recursively in all workspace packages\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Include workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Update only devDependencies\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Update only dependencies (production)\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Interactive mode\n        #[arg(short = 'i', long)]\n        interactive: bool,\n\n        /// Don't update optionalDependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Update lockfile only, don't modify package.json\n        #[arg(long)]\n        no_save: bool,\n\n        /// Only update if package exists in workspace (pnpm-specific)\n        #[arg(long)]\n        workspace: bool,\n\n        /// Packages to update (optional - updates all if omitted)\n        packages: Vec<String>,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Deduplicate dependencies\n    Dedupe {\n        /// Check if deduplication would make changes\n        #[arg(long)]\n        check: bool,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Check for outdated packages\n    Outdated {\n        /// Package name(s) to check\n        packages: Vec<String>,\n\n        /// Show extended information\n        #[arg(long)]\n        long: bool,\n\n        /// Output format: table (default), list, or json\n        #[arg(long, value_name = \"FORMAT\", value_parser = clap::value_parser!(Format))]\n        format: Option<Format>,\n\n        /// Check recursively across all workspaces\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Include workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Only production and optional dependencies\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only dev dependencies\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Exclude optional dependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Only show compatible versions\n        #[arg(long)]\n        compatible: bool,\n\n        /// Sort results by field\n        #[arg(long, value_name = \"FIELD\")]\n        sort_by: Option<String>,\n\n        /// Check globally installed packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Show why a package is installed\n    #[command(visible_alias = \"explain\")]\n    Why {\n        /// Package(s) to check\n        #[arg(required = true)]\n        packages: Vec<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Show extended information\n        #[arg(long)]\n        long: bool,\n\n        /// Show parseable output\n        #[arg(long)]\n        parseable: bool,\n\n        /// Check recursively across all workspaces\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Check in workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Only production dependencies\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only dev dependencies\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Limit tree depth\n        #[arg(long)]\n        depth: Option<u32>,\n\n        /// Exclude optional dependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Check globally installed packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Exclude peer dependencies\n        #[arg(long)]\n        exclude_peers: bool,\n\n        /// Use a finder function defined in .pnpmfile.cjs\n        #[arg(long, value_name = \"FINDER_NAME\")]\n        find_by: Option<String>,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// View package information from the registry\n    #[command(visible_alias = \"view\", visible_alias = \"show\")]\n    Info {\n        /// Package name with optional version\n        #[arg(required = true)]\n        package: String,\n\n        /// Specific field to view\n        field: Option<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Link packages for local development\n    #[command(visible_alias = \"ln\")]\n    Link {\n        /// Package name or directory to link\n        #[arg(value_name = \"PACKAGE|DIR\")]\n        package: Option<String>,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Unlink packages\n    Unlink {\n        /// Package name to unlink\n        #[arg(value_name = \"PACKAGE|DIR\")]\n        package: Option<String>,\n\n        /// Unlink in every workspace package\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Execute a package binary without installing it\n    Dlx {\n        /// Package(s) to install before running\n        #[arg(long, short = 'p', value_name = \"NAME\")]\n        package: Vec<String>,\n\n        /// Execute within a shell environment\n        #[arg(long = \"shell-mode\", short = 'c')]\n        shell_mode: bool,\n\n        /// Suppress all output except the executed command's output\n        #[arg(long, short = 's')]\n        silent: bool,\n\n        /// Package to execute and arguments\n        #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Forward a command to the package manager\n    #[command(subcommand)]\n    Pm(PmCommands),\n\n    // =========================================================================\n    // Category B: JS Script Commands\n    // These commands are implemented in JavaScript and executed via managed Node.js\n    // =========================================================================\n    /// Create a new project from a template (delegates to JS)\n    #[command(disable_help_flag = true)]\n    Create {\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Migrate an existing project to Vite+ (delegates to JS)\n    #[command(disable_help_flag = true)]\n    Migrate {\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// In-repo configuration (hooks, agent integration)\n    #[command(disable_help_flag = true)]\n    Config {\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Run vite-staged on Git staged files\n    #[command(disable_help_flag = true, name = \"staged\")]\n    Staged {\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    // =========================================================================\n    // Category C: Local CLI Delegation (stubs for now)\n    // =========================================================================\n    /// Run the development server\n    #[command(disable_help_flag = true)]\n    Dev {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Build application\n    #[command(disable_help_flag = true)]\n    Build {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Run tests\n    #[command(disable_help_flag = true)]\n    Test {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Lint code\n    #[command(disable_help_flag = true)]\n    Lint {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Format code\n    #[command(disable_help_flag = true)]\n    Fmt {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Run format, lint, and type checks\n    #[command(disable_help_flag = true)]\n    Check {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Build library\n    #[command(disable_help_flag = true)]\n    Pack {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Run tasks\n    #[command(disable_help_flag = true)]\n    Run {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Execute a command from local node_modules/.bin\n    #[command(disable_help_flag = true)]\n    Exec {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Preview production build\n    #[command(disable_help_flag = true)]\n    Preview {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Manage the task cache\n    #[command(disable_help_flag = true)]\n    Cache {\n        /// Additional arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n\n    /// Manage Node.js versions\n    Env(EnvArgs),\n\n    // =========================================================================\n    // Self-Management\n    // =========================================================================\n    /// Update vp itself to the latest version\n    #[command(name = \"upgrade\")]\n    Upgrade {\n        /// Target version (e.g., \"0.2.0\"). Defaults to latest.\n        version: Option<String>,\n\n        /// npm dist-tag to install (default: \"latest\", also: \"alpha\")\n        #[arg(long, default_value = \"latest\")]\n        tag: String,\n\n        /// Check for updates without installing\n        #[arg(long)]\n        check: bool,\n\n        /// Revert to the previously active version\n        #[arg(long)]\n        rollback: bool,\n\n        /// Force reinstall even if already on the target version\n        #[arg(long)]\n        force: bool,\n\n        /// Suppress output\n        #[arg(long)]\n        silent: bool,\n\n        /// Custom npm registry URL\n        #[arg(long)]\n        registry: Option<String>,\n    },\n\n    /// Remove vp and all related data\n    Implode {\n        /// Skip confirmation prompt\n        #[arg(long, short = 'y')]\n        yes: bool,\n    },\n}\n\n/// Arguments for the `env` command\n#[derive(clap::Args, Debug)]\n#[command(after_help = \"\\\nExamples:\n  Setup:\n    vp env setup                  # Create shims for node, npm, npx\n    vp env on                     # Use vite-plus managed Node.js\n    vp env print                  # Print shell snippet for this session\n\n  Manage:\n    vp env pin lts                # Pin to latest LTS version\n    vp env install                # Install version from .node-version / package.json\n    vp env use 20                 # Use Node.js 20 for this shell session\n    vp env use --unset            # Remove session override\n\n  Inspect:\n    vp env current                # Show current resolved environment\n    vp env current --json         # JSON output for automation\n    vp env doctor                 # Check environment configuration\n    vp env which node             # Show which node binary will be used\n    vp env list-remote --lts      # List only LTS versions\n\n  Execute:\n    vp env exec --node lts npm i  # Execute 'npm i' with latest LTS\n    vp env exec node -v           # Shim mode (version auto-resolved)\n\nRelated Commands:\n  vp install -g <package>       # Install a package globally\n  vp uninstall -g <package>     # Uninstall a package globally\n  vp update -g [package]        # Update global packages\n  vp list -g [package]          # List global packages\")]\npub struct EnvArgs {\n    /// Subcommand (e.g., 'default', 'setup', 'doctor', 'which')\n    #[command(subcommand)]\n    pub command: Option<EnvSubcommands>,\n}\n\n/// Subcommands for the `env` command\n#[derive(clap::Subcommand, Debug)]\npub enum EnvSubcommands {\n    /// Show current environment information\n    Current {\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n    },\n\n    /// Print shell snippet to set environment for current session\n    Print,\n\n    /// Set or show the global default Node.js version\n    Default {\n        /// Version to set as default (e.g., \"20.18.0\", \"lts\", \"latest\")\n        /// If not provided, shows the current default\n        version: Option<String>,\n    },\n\n    /// Enable managed mode - shims always use vite-plus managed Node.js\n    On,\n\n    /// Enable system-first mode - shims prefer system Node.js, fallback to managed\n    Off,\n\n    /// Create or update shims in VITE_PLUS_HOME/bin\n    Setup {\n        /// Force refresh shims even if they exist\n        #[arg(long)]\n        refresh: bool,\n        /// Only create env files (skip shims and instructions)\n        #[arg(long)]\n        env_only: bool,\n    },\n\n    /// Run diagnostics and show environment status\n    Doctor,\n\n    /// Show path to the tool that would be executed\n    Which {\n        /// Tool name (node, npm, or npx)\n        tool: String,\n    },\n\n    /// Pin a Node.js version in the current directory (creates .node-version)\n    Pin {\n        /// Version to pin (e.g., \"20.18.0\", \"lts\", \"latest\", \"^20.0.0\")\n        /// If not provided, shows the current pinned version\n        version: Option<String>,\n\n        /// Remove the .node-version file from current directory\n        #[arg(long)]\n        unpin: bool,\n\n        /// Skip pre-downloading the pinned version\n        #[arg(long)]\n        no_install: bool,\n\n        /// Overwrite existing .node-version without confirmation\n        #[arg(long)]\n        force: bool,\n    },\n\n    /// Remove the .node-version file from current directory (alias for `pin --unpin`)\n    Unpin,\n\n    /// List locally installed Node.js versions\n    #[command(visible_alias = \"ls\")]\n    List {\n        /// Output as JSON\n        #[arg(long)]\n        json: bool,\n    },\n\n    /// List available Node.js versions from the registry\n    #[command(name = \"list-remote\", visible_alias = \"ls-remote\")]\n    ListRemote {\n        /// Filter versions by pattern (e.g., \"20\" for 20.x versions)\n        pattern: Option<String>,\n\n        /// Show only LTS versions\n        #[arg(long)]\n        lts: bool,\n\n        /// Show all versions (not just recent)\n        #[arg(long)]\n        all: bool,\n\n        /// Output as JSON\n        #[arg(long)]\n        json: bool,\n\n        /// Version sorting order\n        #[arg(long, value_enum, default_value_t = SortingMethod::Asc)]\n        sort: SortingMethod,\n    },\n\n    /// Execute a command with a specific Node.js version\n    #[command(visible_alias = \"run\")]\n    Exec {\n        /// Node.js version to use (e.g., \"20.18.0\", \"lts\", \"^20.0.0\")\n        /// If not provided and command is node/npm/npx or a global package binary,\n        /// version is resolved automatically (same as shim behavior)\n        #[arg(long)]\n        node: Option<String>,\n\n        /// npm version to use (optional, defaults to bundled)\n        #[arg(long)]\n        npm: Option<String>,\n\n        /// Command and arguments to run\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        command: Vec<String>,\n    },\n\n    /// Uninstall a Node.js version\n    #[command(visible_alias = \"uni\")]\n    Uninstall {\n        /// Version to uninstall (e.g., \"20.18.0\")\n        #[arg(required = true)]\n        version: String,\n    },\n\n    /// Install a Node.js version\n    #[command(visible_alias = \"i\")]\n    Install {\n        /// Version to install (e.g., \"20\", \"20.18.0\", \"lts\", \"latest\")\n        /// If not provided, installs the version from .node-version or package.json\n        version: Option<String>,\n    },\n\n    /// Use a specific Node.js version for this shell session\n    Use {\n        /// Version to use (e.g., \"20\", \"20.18.0\", \"lts\", \"latest\")\n        /// If not provided, reads from .node-version or package.json\n        version: Option<String>,\n\n        /// Remove session override (revert to file-based resolution)\n        #[arg(long)]\n        unset: bool,\n\n        /// Skip auto-installation if version not present\n        #[arg(long)]\n        no_install: bool,\n\n        /// Suppress output if version is already active\n        #[arg(long)]\n        silent_if_unchanged: bool,\n    },\n}\n\n/// Version sorting order for list-remote command\n#[derive(clap::ValueEnum, Clone, Debug, Default)]\npub enum SortingMethod {\n    /// Sort versions in ascending order (earliest to latest)\n    #[default]\n    Asc,\n    /// Sort versions in descending order (latest to earliest)\n    Desc,\n}\n\n/// Package manager subcommands\n#[derive(Subcommand, Debug, Clone)]\npub enum PmCommands {\n    /// Remove unnecessary packages\n    Prune {\n        /// Remove devDependencies\n        #[arg(long)]\n        prod: bool,\n\n        /// Remove optional dependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Create a tarball of the package\n    Pack {\n        /// Pack all workspace packages\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages to pack\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Output path for the tarball\n        #[arg(long)]\n        out: Option<String>,\n\n        /// Directory where the tarball will be saved\n        #[arg(long)]\n        pack_destination: Option<String>,\n\n        /// Gzip compression level (0-9)\n        #[arg(long)]\n        pack_gzip_level: Option<u8>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// List installed packages\n    #[command(visible_alias = \"ls\")]\n    List {\n        /// Package pattern to filter\n        pattern: Option<String>,\n\n        /// Maximum depth of dependency tree\n        #[arg(long)]\n        depth: Option<u32>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Show extended information\n        #[arg(long)]\n        long: bool,\n\n        /// Parseable output format\n        #[arg(long)]\n        parseable: bool,\n\n        /// Only production dependencies\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only dev dependencies\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Exclude optional dependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Exclude peer dependencies\n        #[arg(long)]\n        exclude_peers: bool,\n\n        /// Show only project packages\n        #[arg(long)]\n        only_projects: bool,\n\n        /// Use a finder function\n        #[arg(long, value_name = \"FINDER_NAME\")]\n        find_by: Option<String>,\n\n        /// List across all workspaces\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Vec<String>,\n\n        /// List global packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// View package information from the registry\n    #[command(visible_alias = \"info\", visible_alias = \"show\")]\n    View {\n        /// Package name with optional version\n        #[arg(required = true)]\n        package: String,\n\n        /// Specific field to view\n        field: Option<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Publish package to registry\n    Publish {\n        /// Tarball or folder to publish\n        #[arg(value_name = \"TARBALL|FOLDER\")]\n        target: Option<String>,\n\n        /// Preview without publishing\n        #[arg(long)]\n        dry_run: bool,\n\n        /// Publish tag\n        #[arg(long)]\n        tag: Option<String>,\n\n        /// Access level (public/restricted)\n        #[arg(long)]\n        access: Option<String>,\n\n        /// One-time password for authentication\n        #[arg(long, value_name = \"OTP\")]\n        otp: Option<String>,\n\n        /// Skip git checks\n        #[arg(long)]\n        no_git_checks: bool,\n\n        /// Set the branch name to publish from\n        #[arg(long, value_name = \"BRANCH\")]\n        publish_branch: Option<String>,\n\n        /// Save publish summary\n        #[arg(long)]\n        report_summary: bool,\n\n        /// Force publish\n        #[arg(long)]\n        force: bool,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Publish all workspace packages\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Manage package owners\n    #[command(subcommand, visible_alias = \"author\")]\n    Owner(OwnerCommands),\n\n    /// Manage package cache\n    Cache {\n        /// Subcommand: dir, path, clean\n        #[arg(required = true)]\n        subcommand: String,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Manage package manager configuration\n    #[command(subcommand, visible_alias = \"c\")]\n    Config(ConfigCommands),\n\n    /// Log in to a registry\n    #[command(visible_alias = \"adduser\")]\n    Login {\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Scope for the login\n        #[arg(long, value_name = \"SCOPE\")]\n        scope: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Log out from a registry\n    Logout {\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Scope for the logout\n        #[arg(long, value_name = \"SCOPE\")]\n        scope: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Show the current logged-in user\n    Whoami {\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Manage authentication tokens\n    #[command(subcommand)]\n    Token(TokenCommands),\n\n    /// Run a security audit\n    Audit {\n        /// Automatically fix vulnerabilities\n        #[arg(long)]\n        fix: bool,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Minimum vulnerability level to report\n        #[arg(long, value_name = \"LEVEL\")]\n        level: Option<String>,\n\n        /// Only audit production dependencies\n        #[arg(long)]\n        production: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Manage distribution tags\n    #[command(name = \"dist-tag\", subcommand)]\n    DistTag(DistTagCommands),\n\n    /// Deprecate a package version\n    Deprecate {\n        /// Package name with version (e.g., \"my-pkg@1.0.0\")\n        package: String,\n\n        /// Deprecation message\n        message: String,\n\n        /// One-time password for authentication\n        #[arg(long, value_name = \"OTP\")]\n        otp: Option<String>,\n\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Search for packages in the registry\n    Search {\n        /// Search terms\n        #[arg(required = true, num_args = 1..)]\n        terms: Vec<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Show extended information\n        #[arg(long)]\n        long: bool,\n\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Rebuild native modules\n    #[command(visible_alias = \"rb\")]\n    Rebuild {\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Show funding information for installed packages\n    Fund {\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Ping the registry\n    Ping {\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n}\n\n/// Configuration subcommands\n#[derive(Subcommand, Debug, Clone)]\npub enum ConfigCommands {\n    /// List all configuration\n    List {\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Use global config\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Config location: project (default) or global\n        #[arg(long, value_name = \"LOCATION\")]\n        location: Option<String>,\n    },\n\n    /// Get configuration value\n    Get {\n        /// Config key\n        key: String,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Use global config\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Config location\n        #[arg(long, value_name = \"LOCATION\")]\n        location: Option<String>,\n    },\n\n    /// Set configuration value\n    Set {\n        /// Config key\n        key: String,\n\n        /// Config value\n        value: String,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Use global config\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Config location\n        #[arg(long, value_name = \"LOCATION\")]\n        location: Option<String>,\n    },\n\n    /// Delete configuration key\n    Delete {\n        /// Config key\n        key: String,\n\n        /// Use global config\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Config location\n        #[arg(long, value_name = \"LOCATION\")]\n        location: Option<String>,\n    },\n}\n\n/// Owner subcommands\n#[derive(Subcommand, Debug, Clone)]\npub enum OwnerCommands {\n    /// List package owners\n    #[command(visible_alias = \"ls\")]\n    List {\n        /// Package name\n        package: String,\n\n        /// One-time password for authentication\n        #[arg(long, value_name = \"OTP\")]\n        otp: Option<String>,\n    },\n\n    /// Add package owner\n    Add {\n        /// Username\n        user: String,\n        /// Package name\n        package: String,\n\n        /// One-time password for authentication\n        #[arg(long, value_name = \"OTP\")]\n        otp: Option<String>,\n    },\n\n    /// Remove package owner\n    Rm {\n        /// Username\n        user: String,\n        /// Package name\n        package: String,\n\n        /// One-time password for authentication\n        #[arg(long, value_name = \"OTP\")]\n        otp: Option<String>,\n    },\n}\n\n/// Token subcommands\n#[derive(Subcommand, Debug, Clone)]\npub enum TokenCommands {\n    /// List all known tokens\n    #[command(visible_alias = \"ls\")]\n    List {\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Create a new authentication token\n    Create {\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// CIDR ranges to restrict the token to\n        #[arg(long, value_name = \"CIDR\")]\n        cidr: Option<Vec<String>>,\n\n        /// Create a read-only token\n        #[arg(long)]\n        readonly: bool,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n\n    /// Revoke an authentication token\n    Revoke {\n        /// Token or token ID to revoke\n        token: String,\n\n        /// Registry URL\n        #[arg(long, value_name = \"URL\")]\n        registry: Option<String>,\n\n        /// Additional arguments\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n}\n\n/// Distribution tag subcommands\n#[derive(Subcommand, Debug, Clone)]\npub enum DistTagCommands {\n    /// List distribution tags for a package\n    #[command(visible_alias = \"ls\")]\n    List {\n        /// Package name\n        package: Option<String>,\n    },\n\n    /// Add a distribution tag\n    Add {\n        /// Package name with version (e.g., \"my-pkg@1.0.0\")\n        package_at_version: String,\n\n        /// Tag name\n        tag: String,\n    },\n\n    /// Remove a distribution tag\n    Rm {\n        /// Package name\n        package: String,\n\n        /// Tag name\n        tag: String,\n    },\n}\n\n/// Determine the save dependency type from CLI flags.\nfn determine_save_dependency_type(\n    save_dev: bool,\n    save_peer: bool,\n    save_optional: bool,\n    save_prod: bool,\n) -> Option<SaveDependencyType> {\n    if save_dev {\n        Some(SaveDependencyType::Dev)\n    } else if save_peer {\n        Some(SaveDependencyType::Peer)\n    } else if save_optional {\n        Some(SaveDependencyType::Optional)\n    } else if save_prod {\n        Some(SaveDependencyType::Production)\n    } else {\n        None\n    }\n}\n\nfn has_flag_before_terminator(args: &[String], flag: &str) -> bool {\n    for arg in args {\n        if arg == \"--\" {\n            break;\n        }\n        if arg == flag || arg.starts_with(&format!(\"{flag}=\")) {\n            return true;\n        }\n    }\n    false\n}\n\nfn should_force_global_delegate(command: &str, args: &[String]) -> bool {\n    match command {\n        \"lint\" => has_flag_before_terminator(args, \"--init\"),\n        \"fmt\" => {\n            has_flag_before_terminator(args, \"--init\")\n                || has_flag_before_terminator(args, \"--migrate\")\n        }\n        _ => false,\n    }\n}\n\n/// Run the CLI command.\npub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus, Error> {\n    run_command_with_options(cwd, args, RenderOptions::default()).await\n}\n\n/// Run the CLI command with rendering options.\npub async fn run_command_with_options(\n    cwd: AbsolutePathBuf,\n    args: Args,\n    render_options: RenderOptions,\n) -> Result<ExitStatus, Error> {\n    // Handle --version flag (Category B: delegates to JS)\n    if args.version {\n        return commands::version::execute(cwd).await;\n    }\n\n    // If no command provided, show help and exit\n    let Some(command) = args.command else {\n        // Use custom help formatting to match the JS CLI output\n        if render_options.show_header {\n            command_with_help().print_help().ok();\n        } else {\n            command_with_help_with_options(render_options).print_help().ok();\n        }\n        println!();\n        // Return a successful exit status since help was requested implicitly\n        return Ok(std::process::ExitStatus::default());\n    };\n\n    match command {\n        // Category A: Package Manager Commands\n        Commands::Install {\n            prod,\n            dev,\n            no_optional,\n            frozen_lockfile,\n            no_frozen_lockfile,\n            lockfile_only,\n            prefer_offline,\n            offline,\n            force,\n            ignore_scripts,\n            no_lockfile,\n            fix_lockfile,\n            shamefully_hoist,\n            resolution_only,\n            silent,\n            filter,\n            workspace_root,\n            save_exact,\n            save_peer,\n            save_optional,\n            save_catalog,\n            global,\n            node,\n            packages,\n            pass_through_args,\n        } => {\n            print_runtime_header(render_options.show_header && !silent);\n            // If packages are provided, redirect to Add command\n            if let Some(pkgs) = packages\n                && !pkgs.is_empty()\n            {\n                // Handle global install via vite-plus managed global install\n                if global {\n                    use crate::commands::env::global_install;\n                    for package in &pkgs {\n                        if let Err(e) =\n                            global_install::install(package, node.as_deref(), force).await\n                        {\n                            eprintln!(\"Failed to install {}: {}\", package, e);\n                            return Ok(exit_status(1));\n                        }\n                    }\n                    return Ok(ExitStatus::default());\n                }\n\n                let save_dependency_type =\n                    determine_save_dependency_type(dev, save_peer, save_optional, prod);\n\n                return AddCommand::new(cwd)\n                    .execute(\n                        &pkgs,\n                        save_dependency_type,\n                        save_exact,\n                        if save_catalog { Some(\"default\") } else { None },\n                        filter.as_deref(),\n                        workspace_root,\n                        false, // workspace_only\n                        global,\n                        None, // allow_build\n                        pass_through_args.as_deref(),\n                    )\n                    .await;\n            }\n\n            // No packages provided, run regular install\n            let options = InstallCommandOptions {\n                prod,\n                dev,\n                no_optional,\n                frozen_lockfile,\n                no_frozen_lockfile,\n                lockfile_only,\n                prefer_offline,\n                offline,\n                force,\n                ignore_scripts,\n                no_lockfile,\n                fix_lockfile,\n                shamefully_hoist,\n                resolution_only,\n                silent,\n                filters: filter.as_deref(),\n                workspace_root,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            InstallCommand::new(cwd).execute(&options).await\n        }\n\n        Commands::Add {\n            save_prod,\n            save_dev,\n            save_peer,\n            save_optional,\n            save_exact,\n            save_catalog_name,\n            save_catalog,\n            allow_build,\n            filter,\n            workspace_root,\n            workspace,\n            global,\n            node,\n            packages,\n            pass_through_args,\n        } => {\n            // Handle global install via vite-plus managed global install\n            if global {\n                use crate::commands::env::global_install;\n                for package in &packages {\n                    if let Err(e) = global_install::install(package, node.as_deref(), false).await {\n                        eprintln!(\"Failed to install {}: {}\", package, e);\n                        return Ok(exit_status(1));\n                    }\n                }\n                return Ok(ExitStatus::default());\n            }\n\n            let save_dependency_type =\n                determine_save_dependency_type(save_dev, save_peer, save_optional, save_prod);\n\n            let catalog_name =\n                if save_catalog { Some(\"default\") } else { save_catalog_name.as_deref() };\n\n            AddCommand::new(cwd)\n                .execute(\n                    &packages,\n                    save_dependency_type,\n                    save_exact,\n                    catalog_name,\n                    filter.as_deref(),\n                    workspace_root,\n                    workspace,\n                    global,\n                    allow_build.as_deref(),\n                    pass_through_args.as_deref(),\n                )\n                .await\n        }\n\n        Commands::Remove {\n            save_dev,\n            save_optional,\n            save_prod,\n            filter,\n            workspace_root,\n            recursive,\n            global,\n            dry_run,\n            packages,\n            pass_through_args,\n        } => {\n            // Handle global uninstall via vite-plus managed global install\n            if global {\n                use crate::commands::env::global_install;\n                for package in &packages {\n                    if let Err(e) = global_install::uninstall(package, dry_run).await {\n                        eprintln!(\"Failed to uninstall {}: {}\", package, e);\n                        return Ok(exit_status(1));\n                    }\n                }\n                return Ok(ExitStatus::default());\n            }\n\n            RemoveCommand::new(cwd)\n                .execute(\n                    &packages,\n                    save_dev,\n                    save_optional,\n                    save_prod,\n                    filter.as_deref(),\n                    workspace_root,\n                    recursive,\n                    global,\n                    pass_through_args.as_deref(),\n                )\n                .await\n        }\n\n        Commands::Update {\n            latest,\n            global,\n            recursive,\n            filter,\n            workspace_root,\n            dev,\n            prod,\n            interactive,\n            no_optional,\n            no_save,\n            workspace,\n            packages,\n            pass_through_args,\n        } => {\n            // Handle global update via vite-plus managed global install\n            if global {\n                use crate::commands::env::{global_install, package_metadata::PackageMetadata};\n\n                let packages_to_update = if packages.is_empty() {\n                    let all = PackageMetadata::list_all().await?;\n                    if all.is_empty() {\n                        println!(\"No global packages installed.\");\n                        return Ok(ExitStatus::default());\n                    }\n                    all.iter().map(|p| p.name.clone()).collect::<Vec<_>>()\n                } else {\n                    packages.clone()\n                };\n                for package in &packages_to_update {\n                    if let Err(e) = global_install::install(package, None, false).await {\n                        eprintln!(\"Failed to update {}: {}\", package, e);\n                        return Ok(exit_status(1));\n                    }\n                }\n                return Ok(ExitStatus::default());\n            }\n\n            UpdateCommand::new(cwd)\n                .execute(\n                    &packages,\n                    latest,\n                    global,\n                    recursive,\n                    filter.as_deref(),\n                    workspace_root,\n                    dev,\n                    prod,\n                    interactive,\n                    no_optional,\n                    no_save,\n                    workspace,\n                    pass_through_args.as_deref(),\n                )\n                .await\n        }\n\n        Commands::Dedupe { check, pass_through_args } => {\n            DedupeCommand::new(cwd).execute(check, pass_through_args.as_deref()).await\n        }\n\n        Commands::Outdated {\n            packages,\n            long,\n            format,\n            recursive,\n            filter,\n            workspace_root,\n            prod,\n            dev,\n            no_optional,\n            compatible,\n            sort_by,\n            global,\n            pass_through_args,\n        } => {\n            OutdatedCommand::new(cwd)\n                .execute(\n                    &packages,\n                    long,\n                    format,\n                    recursive,\n                    filter.as_deref(),\n                    workspace_root,\n                    prod,\n                    dev,\n                    no_optional,\n                    compatible,\n                    sort_by.as_deref(),\n                    global,\n                    pass_through_args.as_deref(),\n                )\n                .await\n        }\n\n        Commands::Why {\n            packages,\n            json,\n            long,\n            parseable,\n            recursive,\n            filter,\n            workspace_root,\n            prod,\n            dev,\n            depth,\n            no_optional,\n            global,\n            exclude_peers,\n            find_by,\n            pass_through_args,\n        } => {\n            WhyCommand::new(cwd)\n                .execute(\n                    &packages,\n                    json,\n                    long,\n                    parseable,\n                    recursive,\n                    filter.as_deref(),\n                    workspace_root,\n                    prod,\n                    dev,\n                    depth,\n                    no_optional,\n                    global,\n                    exclude_peers,\n                    find_by.as_deref(),\n                    pass_through_args.as_deref(),\n                )\n                .await\n        }\n\n        Commands::Info { package, field, json, pass_through_args } => {\n            commands::pm::execute_info(\n                cwd,\n                &package,\n                field.as_deref(),\n                json,\n                pass_through_args.as_deref(),\n            )\n            .await\n        }\n\n        Commands::Link { package, args } => {\n            let pass_through = if args.is_empty() { None } else { Some(args.as_slice()) };\n            LinkCommand::new(cwd).execute(package.as_deref(), pass_through).await\n        }\n\n        Commands::Unlink { package, recursive, args } => {\n            let pass_through = if args.is_empty() { None } else { Some(args.as_slice()) };\n            UnlinkCommand::new(cwd).execute(package.as_deref(), recursive, pass_through).await\n        }\n\n        Commands::Dlx { package, shell_mode, silent, args } => {\n            DlxCommand::new(cwd).execute(package, shell_mode, silent, args).await\n        }\n\n        Commands::Pm(pm_command) => commands::pm::execute_pm_subcommand(cwd, pm_command).await,\n\n        // Category B: JS Script Commands\n        Commands::Create { args } => commands::create::execute(cwd, &args).await,\n\n        Commands::Migrate { args } => commands::migrate::execute(cwd, &args).await,\n\n        Commands::Config { args } => commands::config::execute(cwd, &args).await,\n\n        Commands::Staged { args } => commands::staged::execute(cwd, &args).await,\n\n        // Category C: Local CLI Delegation (stubs)\n        Commands::Dev { args } => {\n            if help::maybe_print_unified_delegate_help(\"dev\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"dev\", &args).await\n        }\n\n        Commands::Build { args } => {\n            if help::maybe_print_unified_delegate_help(\"build\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"build\", &args).await\n        }\n\n        Commands::Test { args } => {\n            if help::maybe_print_unified_delegate_help(\"test\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"test\", &args).await\n        }\n\n        Commands::Lint { args } => {\n            if help::maybe_print_unified_delegate_help(\"lint\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            if should_force_global_delegate(\"lint\", &args) {\n                commands::delegate::execute_global(cwd, \"lint\", &args).await\n            } else {\n                commands::delegate::execute(cwd, \"lint\", &args).await\n            }\n        }\n\n        Commands::Fmt { args } => {\n            if help::maybe_print_unified_delegate_help(\"fmt\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            if should_force_global_delegate(\"fmt\", &args) {\n                commands::delegate::execute_global(cwd, \"fmt\", &args).await\n            } else {\n                commands::delegate::execute(cwd, \"fmt\", &args).await\n            }\n        }\n\n        Commands::Check { args } => {\n            if help::maybe_print_unified_delegate_help(\"check\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"check\", &args).await\n        }\n\n        Commands::Pack { args } => {\n            if help::maybe_print_unified_delegate_help(\"pack\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"pack\", &args).await\n        }\n\n        Commands::Run { args } => {\n            if help::maybe_print_unified_delegate_help(\"run\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::run_or_delegate::execute(cwd, &args).await\n        }\n\n        Commands::Exec { args } => {\n            if help::maybe_print_unified_delegate_help(\"exec\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"exec\", &args).await\n        }\n\n        Commands::Preview { args } => {\n            if help::maybe_print_unified_delegate_help(\"preview\", &args, render_options.show_header)\n            {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"preview\", &args).await\n        }\n\n        Commands::Cache { args } => {\n            if help::maybe_print_unified_delegate_help(\"cache\", &args, render_options.show_header) {\n                return Ok(ExitStatus::default());\n            }\n            print_runtime_header(render_options.show_header);\n            commands::delegate::execute(cwd, \"cache\", &args).await\n        }\n\n        Commands::Env(args) => commands::env::execute(cwd, args).await,\n\n        // Self-Management\n        Commands::Upgrade { version, tag, check, rollback, force, silent, registry } => {\n            commands::upgrade::execute(commands::upgrade::UpgradeOptions {\n                version,\n                tag,\n                check,\n                rollback,\n                force,\n                silent,\n                registry,\n            })\n            .await\n        }\n        Commands::Implode { yes } => commands::implode::execute(yes),\n    }\n}\n\n/// Create an exit status with the given code.\npub(crate) fn exit_status(code: i32) -> ExitStatus {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        ExitStatus::from_raw(code << 8)\n    }\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::ExitStatusExt;\n        ExitStatus::from_raw(code as u32)\n    }\n}\n\nfn print_runtime_header(show_header: bool) {\n    if !show_header {\n        return;\n    }\n    println!(\"{}\", vite_shared::header::vite_plus_header());\n    println!();\n}\n\n/// Build a clap Command with custom help formatting matching the JS CLI output.\npub fn command_with_help() -> clap::Command {\n    command_with_help_with_options(RenderOptions::default())\n}\n\n/// Build a clap Command with custom help formatting and rendering options.\npub fn command_with_help_with_options(render_options: RenderOptions) -> clap::Command {\n    apply_custom_help(Args::command(), render_options)\n}\n\n/// Apply custom help formatting to a clap Command to match the JS CLI output.\nfn apply_custom_help(cmd: clap::Command, render_options: RenderOptions) -> clap::Command {\n    let after_help = help::render_help_doc(&help::top_level_help_doc());\n    let options_heading = help::render_heading(\"Options\");\n    let header = if render_options.show_header {\n        vite_shared::header::vite_plus_header()\n    } else {\n        String::new()\n    };\n    let help_template = format!(\"{header}{{after-help}}\\n{options_heading}\\n{{options}}\\n\");\n\n    cmd.after_help(after_help).help_template(help_template)\n}\n\n/// Parse CLI arguments from a custom args iterator with custom help formatting.\n/// Returns `Err` with the clap error if parsing fails (e.g., unknown command).\npub fn try_parse_args_from(\n    args: impl IntoIterator<Item = String>,\n) -> Result<Args, clap::error::Error> {\n    try_parse_args_from_with_options(args, RenderOptions::default())\n}\n\n/// Parse CLI arguments from a custom args iterator with rendering options.\n/// Returns `Err` with the clap error if parsing fails (e.g., unknown command).\npub fn try_parse_args_from_with_options(\n    args: impl IntoIterator<Item = String>,\n    render_options: RenderOptions,\n) -> Result<Args, clap::error::Error> {\n    let cmd = apply_custom_help(Args::command(), render_options);\n    let matches = cmd.try_get_matches_from(args)?;\n    Args::from_arg_matches(&matches).map_err(|e| e.into())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{has_flag_before_terminator, should_force_global_delegate};\n\n    #[test]\n    fn detects_flag_before_option_terminator() {\n        assert!(has_flag_before_terminator(\n            &[\"--init\".to_string(), \"src/index.ts\".to_string()],\n            \"--init\"\n        ));\n    }\n\n    #[test]\n    fn ignores_flag_after_option_terminator() {\n        assert!(!has_flag_before_terminator(\n            &[\"src/index.ts\".to_string(), \"--\".to_string(), \"--init\".to_string(),],\n            \"--init\"\n        ));\n    }\n\n    #[test]\n    fn lint_init_forces_global_delegate() {\n        assert!(should_force_global_delegate(\"lint\", &[\"--init\".to_string()]));\n    }\n\n    #[test]\n    fn fmt_migrate_forces_global_delegate() {\n        assert!(should_force_global_delegate(\"fmt\", &[\"--migrate=prettier\".to_string()]));\n    }\n\n    #[test]\n    fn non_init_does_not_force_global_delegate() {\n        assert!(!should_force_global_delegate(\"lint\", &[\"src/index.ts\".to_string()]));\n        assert!(!should_force_global_delegate(\"fmt\", &[\"--check\".to_string()]));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/command_picker.rs",
    "content": "//! Interactive top-level command picker for `vp`.\n\nuse std::{\n    io::{self, IsTerminal, Write},\n    ops::ControlFlow,\n};\n\nuse crossterm::{\n    cursor,\n    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},\n    execute,\n    style::{Attribute, Print, ResetColor, SetAttribute, SetForegroundColor},\n    terminal::{self, ClearType},\n};\nuse vite_path::AbsolutePath;\n\nuse crate::commands::has_vite_plus_dependency;\n\nconst NEWLINE: &str = \"\\r\\n\";\nconst SELECTED_COLOR: crossterm::style::Color = crossterm::style::Color::Blue;\nconst SELECTED_MARKER: &str = \"›\";\nconst UNSELECTED_MARKER: &str = \" \";\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub struct PickedCommand {\n    pub command: &'static str,\n    pub append_help: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum TopLevelCommandPick {\n    Skipped,\n    Selected(PickedCommand),\n    Cancelled,\n}\n\n#[derive(Clone, Copy)]\nstruct CommandEntry {\n    label: &'static str,\n    command: &'static str,\n    summary: &'static str,\n    append_help: bool,\n}\n\nconst COMMANDS: &[CommandEntry] = &[\n    CommandEntry {\n        label: \"create\",\n        command: \"create\",\n        summary: \"Create a new project from a template.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"migrate\",\n        command: \"migrate\",\n        summary: \"Migrate an existing project to Vite+.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"dev\",\n        command: \"dev\",\n        summary: \"Run the development server.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"check\",\n        command: \"check\",\n        summary: \"Run format, lint, and type checks.\",\n        append_help: false,\n    },\n    CommandEntry { label: \"test\", command: \"test\", summary: \"Run tests.\", append_help: false },\n    CommandEntry {\n        label: \"install\",\n        command: \"install\",\n        summary: \"Install dependencies, or add packages when names are provided.\",\n        append_help: false,\n    },\n    CommandEntry { label: \"run\", command: \"run\", summary: \"Run tasks.\", append_help: false },\n    CommandEntry {\n        label: \"build\",\n        command: \"build\",\n        summary: \"Build for production.\",\n        append_help: false,\n    },\n    CommandEntry { label: \"pack\", command: \"pack\", summary: \"Build library.\", append_help: false },\n    CommandEntry {\n        label: \"preview\",\n        command: \"preview\",\n        summary: \"Preview production build.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"config\",\n        command: \"config\",\n        summary: \"Configure hooks and agent integration.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"outdated\",\n        command: \"outdated\",\n        summary: \"Check for outdated packages.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"env\",\n        command: \"env\",\n        summary: \"Manage Node.js versions.\",\n        append_help: false,\n    },\n    CommandEntry {\n        label: \"help\",\n        command: \"help\",\n        summary: \"View all commands and details\",\n        append_help: false,\n    },\n];\n\nconst CI_ENV_VARS: &[&str] = &[\n    \"CI\",\n    \"CONTINUOUS_INTEGRATION\",\n    \"GITHUB_ACTIONS\",\n    \"GITLAB_CI\",\n    \"CIRCLECI\",\n    \"TRAVIS\",\n    \"JENKINS_URL\",\n    \"BUILDKITE\",\n    \"DRONE\",\n    \"CODEBUILD_BUILD_ID\",\n    \"TF_BUILD\",\n];\n\npub fn pick_top_level_command_if_interactive(\n    cwd: &AbsolutePath,\n) -> io::Result<TopLevelCommandPick> {\n    if !should_enable_picker() {\n        return Ok(TopLevelCommandPick::Skipped);\n    }\n\n    let command_order = default_command_order(has_vite_plus_dependency(cwd));\n\n    Ok(match run_picker(&command_order)? {\n        Some(selection) => TopLevelCommandPick::Selected(selection),\n        None => TopLevelCommandPick::Cancelled,\n    })\n}\n\nfn should_enable_picker() -> bool {\n    std::io::stdin().is_terminal()\n        && std::io::stdout().is_terminal()\n        && std::env::var(\"TERM\").map_or(true, |term| term != \"dumb\")\n        && !is_ci_environment()\n}\n\nfn is_ci_environment() -> bool {\n    CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some())\n}\n\nfn run_picker(command_order: &[usize]) -> io::Result<Option<PickedCommand>> {\n    let mut stdout = io::stdout();\n    let mut selected_position = 0usize;\n    let mut viewport_start = 0usize;\n    let mut query = String::new();\n\n    let is_warp = vite_shared::header::is_warp_terminal();\n    let header_overhead = if is_warp { 10 } else { 9 };\n\n    terminal::enable_raw_mode()?;\n    execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)?;\n\n    let pick_result = loop {\n        let filtered_indices = filtered_command_indices(&query, command_order);\n        if filtered_indices.is_empty() {\n            selected_position = 0;\n            viewport_start = 0;\n        } else {\n            if selected_position >= filtered_indices.len() {\n                selected_position = 0;\n            }\n            viewport_start = viewport_start.min(filtered_indices.len().saturating_sub(1));\n        }\n\n        let (_, rows) = terminal::size().unwrap_or((80, 24));\n        let rows = if rows == 0 { 24 } else { rows };\n        let viewport_size =\n            compute_viewport_size(rows.into(), filtered_indices.len(), header_overhead);\n        viewport_start = align_viewport(viewport_start, selected_position, viewport_size);\n        match render_picker(\n            &mut stdout,\n            &query,\n            &filtered_indices,\n            selected_position,\n            viewport_start,\n            viewport_size,\n        ) {\n            Ok(()) => {}\n            Err(err) => break Err(err),\n        }\n\n        match event::read() {\n            Ok(Event::Key(KeyEvent { code, modifiers, kind, .. })) => {\n                if kind == KeyEventKind::Press {\n                    match handle_key_event(\n                        code,\n                        modifiers,\n                        &mut query,\n                        &mut selected_position,\n                        filtered_indices.len(),\n                    ) {\n                        ControlFlow::Continue(()) => continue,\n                        ControlFlow::Break(Some(())) => {\n                            let Some(index) = filtered_indices.get(selected_position).copied()\n                            else {\n                                continue;\n                            };\n                            break Ok(Some(PickedCommand {\n                                command: COMMANDS[index].command,\n                                append_help: COMMANDS[index].append_help,\n                            }));\n                        }\n                        ControlFlow::Break(None) => break Ok(None),\n                    }\n                }\n            }\n            Ok(_) => continue,\n            Err(err) => break Err(err),\n        }\n    };\n\n    let cleanup_result = cleanup_picker(&mut stdout);\n    match (pick_result, cleanup_result) {\n        (Ok(picked), Ok(())) => Ok(picked),\n        (Err(err), _) => Err(err),\n        (Ok(_), Err(err)) => Err(err),\n    }\n}\n\nfn cleanup_picker(stdout: &mut io::Stdout) -> io::Result<()> {\n    terminal::disable_raw_mode()?;\n    execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen, ResetColor)?;\n    Ok(())\n}\n\nfn handle_key_event(\n    code: KeyCode,\n    modifiers: KeyModifiers,\n    query: &mut String,\n    selected_position: &mut usize,\n    filtered_len: usize,\n) -> ControlFlow<Option<()>> {\n    match code {\n        KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => ControlFlow::Break(None),\n        KeyCode::Esc => ControlFlow::Break(None),\n        KeyCode::Backspace => {\n            if !query.is_empty() {\n                query.pop();\n                *selected_position = 0;\n            }\n            ControlFlow::Continue(())\n        }\n        KeyCode::Up => {\n            *selected_position = selected_position.saturating_sub(1);\n            ControlFlow::Continue(())\n        }\n        KeyCode::Down => {\n            if *selected_position + 1 < filtered_len {\n                *selected_position += 1;\n            }\n            ControlFlow::Continue(())\n        }\n        KeyCode::Home => {\n            *selected_position = 0;\n            ControlFlow::Continue(())\n        }\n        KeyCode::End => {\n            *selected_position = filtered_len.saturating_sub(1);\n            ControlFlow::Continue(())\n        }\n        KeyCode::Enter => {\n            if filtered_len == 0 {\n                ControlFlow::Continue(())\n            } else {\n                ControlFlow::Break(Some(()))\n            }\n        }\n        KeyCode::Char(ch) if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {\n            if !ch.is_control() {\n                query.push(ch);\n                *selected_position = 0;\n            }\n            ControlFlow::Continue(())\n        }\n        _ => ControlFlow::Continue(()),\n    }\n}\n\nfn render_picker(\n    stdout: &mut io::Stdout,\n    query: &str,\n    filtered_indices: &[usize],\n    selected_position: usize,\n    viewport_start: usize,\n    viewport_size: usize,\n) -> io::Result<()> {\n    let (columns, _) = terminal::size().unwrap_or((80, 24));\n    let columns = if columns == 0 { 80 } else { columns };\n    // Warp terminal needs extra padding since it renders alternate screen\n    // content flush against the edges of its block-mode renderer.\n    let pad = if vite_shared::header::is_warp_terminal() { \" \" } else { \"\" };\n    let max_width = usize::from(columns).saturating_sub(4 + pad.len());\n    let viewport_end = (viewport_start + viewport_size).min(filtered_indices.len());\n    let instruction = truncate_line(&picker_instruction(query), max_width);\n\n    execute!(stdout, cursor::MoveTo(0, 0), terminal::Clear(ClearType::All),)?;\n    if vite_shared::header::is_warp_terminal() {\n        execute!(stdout, Print(NEWLINE))?;\n    }\n    execute!(\n        stdout,\n        Print(format!(\"{pad}{}\", vite_shared::header::vite_plus_header())),\n        Print(NEWLINE),\n        Print(NEWLINE),\n        Print(format!(\"{pad}{instruction}\")),\n        Print(NEWLINE),\n        Print(NEWLINE)\n    )?;\n\n    if viewport_start > 0 {\n        execute!(\n            stdout,\n            SetForegroundColor(crossterm::style::Color::DarkGrey),\n            Print(format!(\"{pad}  ↑ more\")),\n            Print(NEWLINE),\n            ResetColor\n        )?;\n    }\n\n    for (index, command_index) in filtered_indices[viewport_start..viewport_end].iter().enumerate()\n    {\n        let actual_position = viewport_start + index;\n        let is_selected = actual_position == selected_position;\n        let entry = &COMMANDS[*command_index];\n        let marker = if is_selected { SELECTED_MARKER } else { UNSELECTED_MARKER };\n        let label = truncate_line(entry.label, max_width);\n\n        if entry.command == \"help\" {\n            let (help_label, help_summary) =\n                selected_command_parts(entry.command, entry.summary, max_width);\n            execute!(\n                stdout,\n                SetForegroundColor(crossterm::style::Color::DarkGrey),\n                Print(format!(\"{pad}  {marker} \")),\n                ResetColor\n            )?;\n            if is_selected {\n                execute!(\n                    stdout,\n                    SetForegroundColor(SELECTED_COLOR),\n                    SetAttribute(Attribute::Bold),\n                    Print(help_label),\n                    SetAttribute(Attribute::Reset),\n                    ResetColor\n                )?;\n            } else {\n                execute!(stdout, Print(help_label))?;\n            }\n            if let Some(summary) = help_summary {\n                execute!(\n                    stdout,\n                    SetForegroundColor(crossterm::style::Color::DarkGrey),\n                    Print(\" \"),\n                    Print(summary),\n                    ResetColor\n                )?;\n            }\n            execute!(stdout, Print(NEWLINE))?;\n            continue;\n        }\n\n        if is_selected {\n            let (selected_label, selected_summary) =\n                selected_command_parts(&label, entry.summary, max_width);\n            execute!(\n                stdout,\n                SetForegroundColor(crossterm::style::Color::DarkGrey),\n                Print(format!(\"{pad}  {marker} \")),\n                ResetColor\n            )?;\n            execute!(stdout, SetForegroundColor(SELECTED_COLOR), SetAttribute(Attribute::Bold),)?;\n            execute!(stdout, Print(selected_label))?;\n            execute!(stdout, SetAttribute(Attribute::Reset), ResetColor)?;\n            if let Some(summary) = selected_summary {\n                execute!(\n                    stdout,\n                    SetForegroundColor(crossterm::style::Color::DarkGrey),\n                    Print(\" \"),\n                    Print(summary),\n                    ResetColor\n                )?;\n            }\n            execute!(stdout, Print(NEWLINE))?;\n        } else {\n            execute!(\n                stdout,\n                SetForegroundColor(crossterm::style::Color::DarkGrey),\n                Print(format!(\"{pad}  {marker} \")),\n                ResetColor,\n                Print(label),\n            )?;\n            execute!(stdout, Print(NEWLINE))?;\n        }\n    }\n\n    if viewport_end < filtered_indices.len() {\n        execute!(\n            stdout,\n            SetForegroundColor(crossterm::style::Color::DarkGrey),\n            Print(format!(\"{pad}  ↓ more\")),\n            Print(NEWLINE),\n            ResetColor\n        )?;\n    }\n\n    if filtered_indices.is_empty() {\n        let no_match = if query.is_empty() {\n            \"No common commands available. Run `vp help`.\".to_string()\n        } else {\n            format!(\"No common command matches '{query}'. Run `vp help`.\")\n        };\n        let no_match = truncate_line(&no_match, max_width);\n        execute!(\n            stdout,\n            Print(NEWLINE),\n            SetForegroundColor(crossterm::style::Color::DarkGrey),\n            Print(format!(\"{pad}  \")),\n            Print(no_match),\n            Print(NEWLINE),\n            ResetColor\n        )?;\n    }\n\n    stdout.flush()\n}\n\nfn picker_instruction(query: &str) -> String {\n    format!(\"Select a command (↑/↓, Enter to run, type to search): {query}\")\n}\n\nfn compute_viewport_size(\n    terminal_rows: usize,\n    total_commands: usize,\n    header_overhead: usize,\n) -> usize {\n    terminal_rows.saturating_sub(header_overhead).clamp(6, total_commands.max(6))\n}\n\nfn align_viewport(current_start: usize, selected_index: usize, viewport_size: usize) -> usize {\n    if selected_index < current_start {\n        selected_index\n    } else if selected_index >= current_start + viewport_size {\n        selected_index + 1 - viewport_size\n    } else {\n        current_start\n    }\n}\n\nfn truncate_line(line: &str, max_chars: usize) -> String {\n    if max_chars == 0 {\n        return String::new();\n    }\n\n    let char_count = line.chars().count();\n    if char_count <= max_chars {\n        return line.to_string();\n    }\n\n    if max_chars == 1 {\n        return \"…\".to_string();\n    }\n\n    line.chars().take(max_chars - 1).collect::<String>() + \"…\"\n}\n\nfn selected_command_parts(\n    command: &str,\n    summary: &str,\n    max_chars: usize,\n) -> (String, Option<String>) {\n    let selected_label = format!(\"{command}:\");\n    let selected_label_width = selected_label.chars().count();\n    if max_chars <= selected_label_width {\n        return (truncate_line(&selected_label, max_chars), None);\n    }\n\n    let summary_width = max_chars - selected_label_width - 1;\n    if summary_width == 0 {\n        return (selected_label, None);\n    }\n\n    (selected_label, Some(truncate_line(summary, summary_width)))\n}\n\nfn default_command_order(prioritize_run: bool) -> Vec<usize> {\n    let indices = (0..COMMANDS.len()).collect::<Vec<_>>();\n    if !prioritize_run {\n        return indices;\n    }\n\n    let migrate_index = COMMANDS\n        .iter()\n        .position(|command| command.command == \"migrate\")\n        .expect(\"migrate command should exist\");\n    let run_index = COMMANDS\n        .iter()\n        .position(|command| command.command == \"run\")\n        .expect(\"run command should exist\");\n\n    let mut ordered = Vec::with_capacity(indices.len());\n    ordered.push(run_index);\n    ordered\n        .extend(indices.into_iter().filter(|index| *index != run_index && *index != migrate_index));\n    ordered\n}\n\nfn filtered_command_indices(query: &str, command_order: &[usize]) -> Vec<usize> {\n    let query = query.trim();\n    if query.is_empty() {\n        return command_order.to_vec();\n    }\n\n    let query = query.to_ascii_lowercase();\n    let starts_with_matches = command_order\n        .iter()\n        .copied()\n        .filter(|index| {\n            let command = &COMMANDS[*index];\n            let command_name = command.command.to_ascii_lowercase();\n            command_name.starts_with(&query)\n        })\n        .collect::<Vec<_>>();\n\n    if !starts_with_matches.is_empty() {\n        return starts_with_matches;\n    }\n\n    command_order\n        .iter()\n        .copied()\n        .filter(|index| {\n            let command = &COMMANDS[*index];\n            let command_name = command.command.to_ascii_lowercase();\n            command_name.contains(&query)\n        })\n        .collect::<Vec<_>>()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{\n        COMMANDS, align_viewport, compute_viewport_size, default_command_order,\n        filtered_command_indices, picker_instruction, selected_command_parts,\n    };\n\n    #[test]\n    fn commands_are_unique() {\n        let mut names = COMMANDS.iter().map(|command| command.command).collect::<Vec<_>>();\n        names.sort_unstable();\n        names.dedup();\n        assert_eq!(names.len(), COMMANDS.len());\n    }\n\n    #[test]\n    fn commands_with_required_args_default_to_help() {\n        let expected: [&str; 0] = [];\n        let mut actual = COMMANDS\n            .iter()\n            .filter(|command| command.append_help)\n            .map(|command| command.command)\n            .collect::<Vec<_>>();\n        actual.sort_unstable();\n        assert_eq!(actual, expected);\n    }\n\n    #[test]\n    fn viewport_aligns_to_selected_row() {\n        assert_eq!(align_viewport(0, 0, 8), 0);\n        assert_eq!(align_viewport(0, 6, 8), 0);\n        assert_eq!(align_viewport(0, 8, 8), 1);\n        assert_eq!(align_viewport(5, 2, 8), 2);\n    }\n\n    #[test]\n    fn viewport_size_is_clamped() {\n        assert_eq!(compute_viewport_size(12, 30, 9), 6);\n        assert_eq!(compute_viewport_size(24, 30, 9), 15);\n        assert_eq!(compute_viewport_size(100, 8, 9), 8);\n        // Warp adds 1 extra row of overhead\n        assert_eq!(compute_viewport_size(12, 30, 10), 6);\n        assert_eq!(compute_viewport_size(24, 30, 10), 14);\n    }\n\n    #[test]\n    fn filtering_is_case_insensitive_and_returns_matching_commands_only() {\n        let order = default_command_order(false);\n        let run = filtered_command_indices(\"Ru\", &order);\n        assert_eq!(run.len(), 1);\n        assert_eq!(COMMANDS[run[0]].command, \"run\");\n\n        let build = filtered_command_indices(\"b\", &order);\n        let build_commands = build.iter().map(|index| COMMANDS[*index].command).collect::<Vec<_>>();\n        assert!(build_commands.contains(&\"build\"));\n    }\n\n    #[test]\n    fn filtering_with_no_matches_returns_empty() {\n        let order = default_command_order(false);\n        let no_match = filtered_command_indices(\"xyz123\", &order);\n        assert!(no_match.is_empty());\n    }\n\n    #[test]\n    fn filtering_prefers_prefix_matches() {\n        let order = default_command_order(false);\n        let help = filtered_command_indices(\"he\", &order);\n        assert_eq!(help.len(), 1);\n        assert_eq!(COMMANDS[help[0]].command, \"help\");\n    }\n\n    #[test]\n    fn default_order_puts_create_first_for_non_vite_plus_projects() {\n        let order = default_command_order(false);\n        assert_eq!(COMMANDS[order[0]].command, \"create\");\n    }\n\n    #[test]\n    fn default_order_puts_run_first_for_vite_plus_projects() {\n        let order = default_command_order(true);\n        assert_eq!(COMMANDS[order[0]].command, \"run\");\n    }\n\n    #[test]\n    fn default_order_hides_migrate_for_vite_plus_projects() {\n        let order = default_command_order(true);\n        let ordered_commands =\n            order.iter().map(|index| COMMANDS[*index].command).collect::<Vec<_>>();\n        assert!(!ordered_commands.contains(&\"migrate\"));\n    }\n\n    #[test]\n    fn selected_command_parts_appends_summary() {\n        let (label, summary) = selected_command_parts(\"create\", \"Create a new project.\", 80);\n        assert_eq!(label, \"create:\");\n        assert_eq!(summary, Some(\"Create a new project.\".to_string()));\n    }\n\n    #[test]\n    fn selected_command_parts_truncates_summary_to_fit_width() {\n        let (label, summary) = selected_command_parts(\"create\", \"Create a new project.\", 18);\n        assert_eq!(label, \"create:\");\n        assert_eq!(summary, Some(\"Create a …\".to_string()));\n    }\n\n    #[test]\n    fn selected_command_parts_truncates_label_when_width_is_tight() {\n        let (label, summary) = selected_command_parts(\"create\", \"Create a new project.\", 4);\n        assert_eq!(label, \"cre…\");\n        assert_eq!(summary, None);\n    }\n\n    #[test]\n    fn help_entry_uses_static_inline_description() {\n        let help = COMMANDS\n            .iter()\n            .find(|entry| entry.command == \"help\")\n            .expect(\"help command should exist\");\n        assert_eq!(help.label, \"help\");\n        assert_eq!(help.summary, \"View all commands and details\");\n    }\n\n    #[test]\n    fn picker_instruction_mentions_search() {\n        assert_eq!(\n            picker_instruction(\"\"),\n            \"Select a command (↑/↓, Enter to run, type to search): \"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/add.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::{\n    commands::add::{AddCommandOptions, SaveDependencyType},\n    package_manager::PackageManager,\n};\nuse vite_path::AbsolutePathBuf;\n\nuse super::prepend_js_runtime_to_path_env;\nuse crate::error::Error;\n\n/// Add command for adding packages to dependencies.\n///\n/// This command automatically detects the package manager and translates\n/// the add command to the appropriate package manager-specific syntax.\npub struct AddCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl AddCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        packages: &[String],\n        save_dependency_type: Option<SaveDependencyType>,\n        save_exact: bool,\n        save_catalog_name: Option<&str>,\n        filters: Option<&[String]>,\n        workspace_root: bool,\n        workspace_only: bool,\n        global: bool,\n        allow_build: Option<&str>,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n        super::ensure_package_json(&self.cwd).await?;\n\n        let add_command_options = AddCommandOptions {\n            packages,\n            save_dependency_type,\n            save_exact,\n            filters,\n            workspace_root,\n            workspace_only,\n            global,\n            save_catalog_name,\n            allow_build,\n            pass_through_args,\n        };\n\n        // Detect package manager\n        let package_manager = PackageManager::builder(&self.cwd).build_with_default().await?;\n\n        Ok(package_manager.run_add_command(&add_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_add_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = AddCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/config.rs",
    "content": "//! In-repo configuration command (Category B: JavaScript Command).\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::error::Error;\n\n/// Execute the `config` command by delegating to local or global vite-plus.\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    super::delegate::execute(cwd, \"config\", args).await\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/create.rs",
    "content": "//! Project scaffolding command (Category B: JavaScript Command).\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::error::Error;\n\n/// Execute the `create` command by delegating to local or global vite-plus.\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    super::delegate::execute(cwd, \"create\", args).await\n}\n\n#[cfg(test)]\nmod tests {\n    #[test]\n    fn test_create_command_module_exists() {\n        // Basic test to ensure the module compiles\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/dedupe.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::dedupe::DedupeCommandOptions;\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Dedupe command for deduplicating dependencies by removing older versions.\n///\n/// This command automatically detects the package manager and translates\n/// the dedupe command to the appropriate package manager-specific syntax.\npub struct DedupeCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl DedupeCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        check: bool,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let dedupe_command_options = DedupeCommandOptions { check, pass_through_args };\n        Ok(package_manager.run_dedupe_command(&dedupe_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_dedupe_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = DedupeCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/delegate.rs",
    "content": "//! JavaScript command delegation — resolves local vite-plus first, falls back to global.\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::{error::Error, js_executor::JsExecutor};\n\n/// Execute a command by delegating to the local `vite-plus` CLI.\npub async fn execute(\n    cwd: AbsolutePathBuf,\n    command: &str,\n    args: &[String],\n) -> Result<ExitStatus, Error> {\n    let mut executor = JsExecutor::new(None);\n    let mut full_args = vec![command.to_string()];\n    full_args.extend(args.iter().cloned());\n    executor.delegate_to_local_cli(&cwd, &full_args).await\n}\n\n/// Execute a command by delegating to the global `vite-plus` CLI.\npub async fn execute_global(\n    cwd: AbsolutePathBuf,\n    command: &str,\n    args: &[String],\n) -> Result<ExitStatus, Error> {\n    let mut executor = JsExecutor::new(None);\n    let mut full_args = vec![command.to_string()];\n    full_args.extend(args.iter().cloned());\n    executor.delegate_to_global_cli(&cwd, &full_args).await\n}\n\n#[cfg(test)]\nmod tests {\n    #[test]\n    fn test_delegate_command_module_exists() {\n        // Basic test to ensure the module compiles\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/dlx.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_install::{\n    commands::dlx::{DlxCommandOptions, build_npx_args},\n    package_manager::PackageManager,\n};\nuse vite_path::AbsolutePathBuf;\n\nuse super::prepend_js_runtime_to_path_env;\nuse crate::error::Error;\n\n/// Dlx command for executing packages without installing them as dependencies.\n///\n/// This command automatically detects the package manager and translates\n/// the dlx command to the appropriate package manager-specific syntax:\n/// - pnpm: pnpm dlx\n/// - npm: npm exec\n/// - yarn@2+: yarn dlx\n/// - yarn@1: falls back to npx\n///\n/// When no package.json is found, falls back to npx directly.\npub struct DlxCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl DlxCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        packages: Vec<String>,\n        shell_mode: bool,\n        silent: bool,\n        args: Vec<String>,\n    ) -> Result<ExitStatus, Error> {\n        if args.is_empty() {\n            return Err(Error::Other(\"dlx requires a package name\".into()));\n        }\n\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        // First arg is the package spec, rest are command args\n        let package_spec = &args[0];\n        let command_args: Vec<String> = args[1..].to_vec();\n\n        let dlx_command_options = DlxCommandOptions {\n            packages: &packages,\n            package_spec,\n            args: &command_args,\n            shell_mode,\n            silent,\n        };\n\n        match PackageManager::builder(&self.cwd).build_with_default().await {\n            Ok(pm) => Ok(pm.run_dlx_command(&dlx_command_options, &self.cwd).await?),\n            Err(vite_error::Error::WorkspaceError(vite_workspace::Error::PackageJsonNotFound(\n                _,\n            ))) => {\n                // No package.json found — fall back to npx directly\n                let args = build_npx_args(&dlx_command_options);\n                let envs = HashMap::new();\n                Ok(run_command(\"npx\", &args, &envs, &self.cwd).await?)\n            }\n            Err(e) => Err(e.into()),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_dlx_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = DlxCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/bin_config.rs",
    "content": "//! Per-binary configuration storage for global packages.\n//!\n//! Each binary installed via `vp install -g` gets a config file at\n//! `~/.vite-plus/bins/{name}.json` that tracks which package owns it.\n//! This enables:\n//! - Deterministic binary-to-package resolution\n//! - Conflict detection when installing packages with overlapping binaries\n//! - Safe uninstall (only removes binaries owned by the package)\n\nuse serde::{Deserialize, Serialize};\nuse vite_path::AbsolutePathBuf;\n\nuse super::config::get_vite_plus_home;\nuse crate::error::Error;\n\n/// Source that installed a binary.\n#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum BinSource {\n    /// Installed via `vp install -g` (managed shim)\n    #[default]\n    Vp,\n    /// Installed via `npm install -g` shim interception (direct symlink)\n    Npm,\n}\n\n/// Config for a single binary, stored at ~/.vite-plus/bins/{name}.json\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct BinConfig {\n    /// Binary name\n    pub name: String,\n    /// Package that installed this binary\n    pub package: String,\n    /// Package version\n    pub version: String,\n    /// Node.js version used\n    pub node_version: String,\n    /// How this binary was installed\n    #[serde(default)]\n    pub source: BinSource,\n}\n\nimpl BinConfig {\n    /// Create a new BinConfig with `Vp` source (used by `vp install -g`).\n    pub fn new(name: String, package: String, version: String, node_version: String) -> Self {\n        Self { name, package, version, node_version, source: BinSource::Vp }\n    }\n\n    /// Create a new BinConfig with `Npm` source (used by npm install -g interception).\n    pub fn new_npm(name: String, package: String, node_version: String) -> Self {\n        Self { name, package, version: String::new(), node_version, source: BinSource::Npm }\n    }\n\n    /// Get the bins directory path (~/.vite-plus/bins/).\n    pub fn bins_dir() -> Result<AbsolutePathBuf, Error> {\n        Ok(get_vite_plus_home()?.join(\"bins\"))\n    }\n\n    /// Get the path to a binary's config file.\n    pub fn path(bin_name: &str) -> Result<AbsolutePathBuf, Error> {\n        Ok(Self::bins_dir()?.join(format!(\"{bin_name}.json\")))\n    }\n\n    /// Load config for a binary (synchronous).\n    pub fn load_sync(bin_name: &str) -> Result<Option<Self>, Error> {\n        let path = Self::path(bin_name)?;\n        match std::fs::read_to_string(path.as_path()) {\n            Ok(content) => {\n                let config: Self = serde_json::from_str(&content).map_err(|e| {\n                    Error::ConfigError(format!(\"Failed to parse bin config: {e}\").into())\n                })?;\n                Ok(Some(config))\n            }\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),\n            Err(e) => Err(e.into()),\n        }\n    }\n\n    /// Save config for a binary (synchronous).\n    pub fn save_sync(&self) -> Result<(), Error> {\n        let path = Self::path(&self.name)?;\n        if let Some(parent) = path.parent() {\n            std::fs::create_dir_all(parent)?;\n        }\n        let content = serde_json::to_string_pretty(self).map_err(|e| {\n            Error::ConfigError(format!(\"Failed to serialize bin config: {e}\").into())\n        })?;\n        std::fs::write(path.as_path(), content)?;\n        Ok(())\n    }\n\n    /// Delete config for a binary (synchronous).\n    pub fn delete_sync(bin_name: &str) -> Result<(), Error> {\n        let path = Self::path(bin_name)?;\n        match std::fs::remove_file(path.as_path()) {\n            Ok(()) => Ok(()),\n            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),\n            Err(e) => Err(e.into()),\n        }\n    }\n\n    /// Load config for a binary.\n    pub async fn load(bin_name: &str) -> Result<Option<Self>, Error> {\n        let path = Self::path(bin_name)?;\n        if !tokio::fs::try_exists(&path).await.unwrap_or(false) {\n            return Ok(None);\n        }\n        let content = tokio::fs::read_to_string(&path).await?;\n        let config: Self = serde_json::from_str(&content)\n            .map_err(|e| Error::ConfigError(format!(\"Failed to parse bin config: {e}\").into()))?;\n        Ok(Some(config))\n    }\n\n    /// Save config for a binary.\n    pub async fn save(&self) -> Result<(), Error> {\n        let path = Self::path(&self.name)?;\n\n        // Ensure bins directory exists\n        if let Some(parent) = path.parent() {\n            tokio::fs::create_dir_all(parent).await?;\n        }\n\n        let content = serde_json::to_string_pretty(self).map_err(|e| {\n            Error::ConfigError(format!(\"Failed to serialize bin config: {e}\").into())\n        })?;\n        tokio::fs::write(&path, content).await?;\n        Ok(())\n    }\n\n    /// Delete config for a binary.\n    pub async fn delete(bin_name: &str) -> Result<(), Error> {\n        let path = Self::path(bin_name)?;\n        if tokio::fs::try_exists(&path).await.unwrap_or(false) {\n            tokio::fs::remove_file(&path).await?;\n        }\n        Ok(())\n    }\n\n    /// Find all binaries installed by a package.\n    ///\n    /// This is used as a fallback during uninstall when PackageMetadata is missing\n    /// (orphan recovery).\n    pub async fn find_by_package(package_name: &str) -> Result<Vec<String>, Error> {\n        let bins_dir = Self::bins_dir()?;\n        if !tokio::fs::try_exists(&bins_dir).await.unwrap_or(false) {\n            return Ok(Vec::new());\n        }\n\n        let mut bins = Vec::new();\n        let mut entries = tokio::fs::read_dir(&bins_dir).await?;\n\n        while let Some(entry) = entries.next_entry().await? {\n            let path = entry.path();\n            if path.extension().is_some_and(|e| e == \"json\") {\n                if let Ok(content) = tokio::fs::read_to_string(&path).await {\n                    if let Ok(config) = serde_json::from_str::<BinConfig>(&content) {\n                        if config.package == package_name {\n                            bins.push(config.name);\n                        }\n                    }\n                }\n            }\n        }\n\n        Ok(bins)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[tokio::test]\n    async fn test_save_and_load() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        let config = BinConfig::new(\n            \"tsc\".to_string(),\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n        );\n        config.save().await.unwrap();\n\n        let loaded = BinConfig::load(\"tsc\").await.unwrap();\n        assert!(loaded.is_some());\n        let loaded = loaded.unwrap();\n        assert_eq!(loaded.name, \"tsc\");\n        assert_eq!(loaded.package, \"typescript\");\n        assert_eq!(loaded.version, \"5.0.0\");\n        assert_eq!(loaded.node_version, \"20.18.0\");\n    }\n\n    #[tokio::test]\n    async fn test_find_by_package() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create configs for typescript (tsc, tsserver)\n        let tsc = BinConfig::new(\n            \"tsc\".to_string(),\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n        );\n        tsc.save().await.unwrap();\n\n        let tsserver = BinConfig::new(\n            \"tsserver\".to_string(),\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n        );\n        tsserver.save().await.unwrap();\n\n        // Create config for eslint\n        let eslint = BinConfig::new(\n            \"eslint\".to_string(),\n            \"eslint\".to_string(),\n            \"9.0.0\".to_string(),\n            \"22.0.0\".to_string(),\n        );\n        eslint.save().await.unwrap();\n\n        // Find by package\n        let ts_bins = BinConfig::find_by_package(\"typescript\").await.unwrap();\n        assert_eq!(ts_bins.len(), 2);\n        assert!(ts_bins.contains(&\"tsc\".to_string()));\n        assert!(ts_bins.contains(&\"tsserver\".to_string()));\n\n        let eslint_bins = BinConfig::find_by_package(\"eslint\").await.unwrap();\n        assert_eq!(eslint_bins.len(), 1);\n        assert!(eslint_bins.contains(&\"eslint\".to_string()));\n\n        let nonexistent_bins = BinConfig::find_by_package(\"nonexistent\").await.unwrap();\n        assert!(nonexistent_bins.is_empty());\n    }\n\n    #[tokio::test]\n    async fn test_delete() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        let config = BinConfig::new(\n            \"tsc\".to_string(),\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n        );\n        config.save().await.unwrap();\n\n        // Verify it exists\n        let loaded = BinConfig::load(\"tsc\").await.unwrap();\n        assert!(loaded.is_some());\n\n        // Delete\n        BinConfig::delete(\"tsc\").await.unwrap();\n\n        // Verify it's gone\n        let loaded = BinConfig::load(\"tsc\").await.unwrap();\n        assert!(loaded.is_none());\n\n        // Delete again should not error\n        BinConfig::delete(\"tsc\").await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_load_nonexistent() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        let loaded = BinConfig::load(\"nonexistent\").await.unwrap();\n        assert!(loaded.is_none());\n    }\n\n    #[test]\n    fn test_source_defaults_to_vp() {\n        let config = BinConfig::new(\n            \"tsc\".to_string(),\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n        );\n        assert_eq!(config.source, BinSource::Vp);\n    }\n\n    #[test]\n    fn test_new_npm_source() {\n        let config = BinConfig::new_npm(\n            \"codex\".to_string(),\n            \"@openai/codex\".to_string(),\n            \"22.22.0\".to_string(),\n        );\n        assert_eq!(config.source, BinSource::Npm);\n        assert_eq!(config.name, \"codex\");\n        assert_eq!(config.package, \"@openai/codex\");\n        assert!(config.version.is_empty());\n        assert_eq!(config.node_version, \"22.22.0\");\n    }\n\n    #[test]\n    fn test_source_backward_compat_deserialize() {\n        // Old BinConfig files without \"source\" field should default to \"vp\"\n        let json =\n            r#\"{\"name\":\"tsc\",\"package\":\"typescript\",\"version\":\"5.0.0\",\"nodeVersion\":\"20.18.0\"}\"#;\n        let config: BinConfig = serde_json::from_str(json).unwrap();\n        assert_eq!(config.source, BinSource::Vp);\n    }\n\n    #[test]\n    fn test_sync_save_load_delete() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        let config = BinConfig::new_npm(\n            \"codex\".to_string(),\n            \"@openai/codex\".to_string(),\n            \"22.22.0\".to_string(),\n        );\n        config.save_sync().unwrap();\n\n        let loaded = BinConfig::load_sync(\"codex\").unwrap();\n        assert!(loaded.is_some());\n        let loaded = loaded.unwrap();\n        assert_eq!(loaded.source, BinSource::Npm);\n        assert_eq!(loaded.package, \"@openai/codex\");\n\n        BinConfig::delete_sync(\"codex\").unwrap();\n        let loaded = BinConfig::load_sync(\"codex\").unwrap();\n        assert!(loaded.is_none());\n\n        // Delete again should not error\n        BinConfig::delete_sync(\"codex\").unwrap();\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/config.rs",
    "content": "//! Configuration and version resolution for the env command.\n//!\n//! This module provides:\n//! - VITE_PLUS_HOME path resolution\n//! - Version resolution with priority order\n//! - Config file management\n\nuse serde::{Deserialize, Serialize};\nuse vite_js_runtime::{\n    NodeProvider, VersionSource, normalize_version, read_package_json, resolve_node_version,\n};\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\n\nuse crate::error::Error;\n\n/// Config file name\nconst CONFIG_FILE: &str = \"config.json\";\n\n/// Shim mode determines how shims resolve tools.\n#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]\n#[serde(rename_all = \"snake_case\")]\npub enum ShimMode {\n    /// Shims always use vite-plus managed Node.js\n    #[default]\n    Managed,\n    /// Shims prefer system Node.js, fallback to managed if not found\n    SystemFirst,\n}\n\n/// User configuration stored in VITE_PLUS_HOME/config.json\n#[derive(Serialize, Deserialize, Default, Debug)]\n#[serde(rename_all = \"camelCase\")]\npub struct Config {\n    /// Default Node.js version when no project version file is found\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub default_node_version: Option<String>,\n    /// Shim mode for tool resolution\n    #[serde(default, skip_serializing_if = \"is_default_shim_mode\")]\n    pub shim_mode: ShimMode,\n}\n\n/// Check if shim mode is the default (for skip_serializing_if)\nfn is_default_shim_mode(mode: &ShimMode) -> bool {\n    *mode == ShimMode::Managed\n}\n\n/// Version resolution result\n#[derive(Debug)]\npub struct VersionResolution {\n    /// The resolved version string (e.g., \"20.18.0\")\n    pub version: String,\n    /// The source of the version (e.g., \".node-version\", \"engines.node\", \"default\")\n    pub source: String,\n    /// Path to the source file (if applicable)\n    pub source_path: Option<AbsolutePathBuf>,\n    /// Project root directory (if version came from a project file)\n    pub project_root: Option<AbsolutePathBuf>,\n    /// Whether the original version spec was a range (e.g., \"20\", \"^20.0.0\", \"lts/*\")\n    /// Range versions should use time-based cache expiry instead of mtime-only validation\n    pub is_range: bool,\n}\n\n/// Get the VITE_PLUS_HOME directory path.\n///\n/// Uses `VITE_PLUS_HOME` environment variable if set, otherwise defaults to `~/.vite-plus`.\npub fn get_vite_plus_home() -> Result<AbsolutePathBuf, Error> {\n    Ok(vite_shared::get_vite_plus_home()?)\n}\n\n/// Get the bin directory path (~/.vite-plus/bin/).\npub fn get_bin_dir() -> Result<AbsolutePathBuf, Error> {\n    Ok(get_vite_plus_home()?.join(\"bin\"))\n}\n\n/// Get the packages directory path (~/.vite-plus/packages/).\npub fn get_packages_dir() -> Result<AbsolutePathBuf, Error> {\n    Ok(get_vite_plus_home()?.join(\"packages\"))\n}\n\n/// Get the tmp directory path for staging (~/.vite-plus/tmp/).\npub fn get_tmp_dir() -> Result<AbsolutePathBuf, Error> {\n    Ok(get_vite_plus_home()?.join(\"tmp\"))\n}\n\n/// Get the node_modules directory path for a package.\n///\n/// npm uses different layouts on Unix vs Windows:\n/// - Unix: `<prefix>/lib/node_modules/<package>`\n/// - Windows: `<prefix>/node_modules/<package>`\n///\n/// This function probes both paths and returns the one that exists,\n/// falling back to the platform default if neither exists.\npub fn get_node_modules_dir(prefix: &AbsolutePath, package_name: &str) -> AbsolutePathBuf {\n    // Try Unix layout first (lib/node_modules)\n    let unix_path = prefix.join(\"lib\").join(\"node_modules\").join(package_name);\n    if unix_path.as_path().exists() {\n        return unix_path;\n    }\n\n    // Try Windows layout (node_modules)\n    let win_path = prefix.join(\"node_modules\").join(package_name);\n    if win_path.as_path().exists() {\n        return win_path;\n    }\n\n    // Neither exists - return platform default (for pre-creation checks)\n    #[cfg(windows)]\n    {\n        win_path\n    }\n    #[cfg(not(windows))]\n    {\n        unix_path\n    }\n}\n\n/// Get the config file path.\npub fn get_config_path() -> Result<AbsolutePathBuf, Error> {\n    Ok(get_vite_plus_home()?.join(CONFIG_FILE))\n}\n\n/// Load configuration from disk.\npub async fn load_config() -> Result<Config, Error> {\n    let config_path = get_config_path()?;\n\n    if !tokio::fs::try_exists(&config_path).await.unwrap_or(false) {\n        return Ok(Config::default());\n    }\n\n    let content = tokio::fs::read_to_string(&config_path).await?;\n    let config: Config = serde_json::from_str(&content)?;\n    Ok(config)\n}\n\n/// Save configuration to disk.\npub async fn save_config(config: &Config) -> Result<(), Error> {\n    let config_path = get_config_path()?;\n    let vite_plus_home = get_vite_plus_home()?;\n\n    // Ensure directory exists\n    tokio::fs::create_dir_all(&vite_plus_home).await?;\n\n    let content = serde_json::to_string_pretty(config)?;\n    tokio::fs::write(&config_path, content).await?;\n    Ok(())\n}\n\n/// Environment variable for per-shell session Node.js version override.\n/// Set by `vp env use` command.\npub const VERSION_ENV_VAR: &str = vite_shared::env_vars::VITE_PLUS_NODE_VERSION;\n\n/// Session version file name, written by `vp env use` so shims work without the shell eval wrapper.\npub const SESSION_VERSION_FILE: &str = \".session-node-version\";\n\n/// Get the path to the session version file (~/.vite-plus/.session-node-version).\npub fn get_session_version_path() -> Result<AbsolutePathBuf, Error> {\n    Ok(get_vite_plus_home()?.join(SESSION_VERSION_FILE))\n}\n\n/// Read the session version file. Returns `None` if the file is missing or empty.\npub async fn read_session_version() -> Option<String> {\n    let path = get_session_version_path().ok()?;\n    let content = tokio::fs::read_to_string(&path).await.ok()?;\n    let trimmed = content.trim().to_string();\n    if trimmed.is_empty() { None } else { Some(trimmed) }\n}\n\n/// Read the session version file synchronously. Returns `None` if the file is missing or empty.\npub fn read_session_version_sync() -> Option<String> {\n    let path = get_session_version_path().ok()?;\n    let content = std::fs::read_to_string(path.as_path()).ok()?;\n    let trimmed = content.trim().to_string();\n    if trimmed.is_empty() { None } else { Some(trimmed) }\n}\n\n/// Write the resolved version to the session version file.\npub async fn write_session_version(version: &str) -> Result<(), Error> {\n    let path = get_session_version_path()?;\n    // Ensure parent directory exists\n    if let Some(parent) = path.parent() {\n        tokio::fs::create_dir_all(parent).await?;\n    }\n    tokio::fs::write(&path, version).await?;\n    Ok(())\n}\n\n/// Delete the session version file. Ignores \"not found\" errors.\npub async fn delete_session_version() -> Result<(), Error> {\n    let path = get_session_version_path()?;\n    match tokio::fs::remove_file(&path).await {\n        Ok(()) => Ok(()),\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),\n        Err(e) => Err(e.into()),\n    }\n}\n\n/// Resolve Node.js version for a directory.\n///\n/// Resolution order:\n/// 0. `VITE_PLUS_NODE_VERSION` env var (session override from `vp env use`)\n/// 1. `.session-node-version` file (session override written by `vp env use` for shell-wrapper-less environments)\n/// 2. `.node-version` file in current or parent directories\n/// 3. `package.json#engines.node` in current or parent directories\n/// 4. `package.json#devEngines.runtime` in current or parent directories\n/// 5. User default from config.json\n/// 6. Latest LTS version\npub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {\n    // Session override via environment variable (set by `vp env use`)\n    if let Some(env_version) = vite_shared::EnvConfig::get().node_version {\n        let env_version = env_version.trim();\n        if !env_version.is_empty() {\n            return Ok(VersionResolution {\n                version: env_version.to_string(),\n                source: VERSION_ENV_VAR.into(),\n                source_path: None,\n                project_root: None,\n                is_range: false,\n            });\n        }\n    }\n\n    // Session override via file (written by `vp env use` for shell-wrapper-less environments)\n    if let Some(session_version) = read_session_version().await {\n        return Ok(VersionResolution {\n            version: session_version,\n            source: SESSION_VERSION_FILE.into(),\n            source_path: get_session_version_path().ok(),\n            project_root: None,\n            is_range: false,\n        });\n    }\n\n    resolve_version_from_files(cwd).await\n}\n\n/// Resolve Node.js version from project files only (skipping session overrides).\n///\n/// This is used by `vp env use` without arguments to revert to file-based resolution.\npub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {\n    let provider = NodeProvider::new();\n\n    // Use shared version resolution with directory walking\n    let resolution = resolve_node_version(cwd, true)\n        .await\n        .map_err(|e| Error::ConfigError(e.to_string().into()))?;\n\n    if let Some(resolution) = resolution {\n        // Validate version before attempting resolution\n        // If invalid, warning is printed by normalize_version and we fall through to defaults\n        if let Some(validated) =\n            normalize_version(&resolution.version.clone().into(), &resolution.source.to_string())\n        {\n            // Detect if the original version spec was a range (not exact)\n            // This includes partial versions (20, 20.18), semver ranges (^20.0.0), LTS aliases, and \"latest\"\n            let is_range = NodeProvider::is_version_alias(&validated)\n                || !NodeProvider::is_exact_version(&validated);\n\n            let resolved = resolve_version_string(&validated, &provider).await?;\n            return Ok(VersionResolution {\n                version: resolved,\n                source: resolution.source.to_string(),\n                source_path: resolution.source_path,\n                project_root: resolution.project_root,\n                is_range,\n            });\n        }\n\n        // Invalid version from a project source - try lower-priority sources in the same directory.\n        // This mirrors the fallback logic in download_runtime_for_project().\n        // - NodeVersionFile: try engines.node, then devEngines.runtime\n        // - EnginesNode: try devEngines.runtime\n        if matches!(resolution.source, VersionSource::NodeVersionFile | VersionSource::EnginesNode)\n        {\n            if let Some(project_root) = &resolution.project_root {\n                let package_json_path = project_root.join(\"package.json\");\n                if let Ok(Some(pkg)) = read_package_json(&package_json_path).await {\n                    // Try engines.node (only when falling back from .node-version)\n                    if matches!(resolution.source, VersionSource::NodeVersionFile) {\n                        if let Some(engines_node) = pkg\n                            .engines\n                            .as_ref()\n                            .and_then(|e| e.node.clone())\n                            .and_then(|v| normalize_version(&v, \"engines.node\"))\n                        {\n                            let resolved = resolve_version_string(&engines_node, &provider).await?;\n                            let is_range = NodeProvider::is_lts_alias(&engines_node)\n                                || !NodeProvider::is_exact_version(&engines_node);\n                            return Ok(VersionResolution {\n                                version: resolved,\n                                source: \"engines.node\".into(),\n                                source_path: Some(package_json_path),\n                                project_root: Some(project_root.clone()),\n                                is_range,\n                            });\n                        }\n                    }\n\n                    // Try devEngines.runtime\n                    if let Some(dev_engines) = pkg\n                        .dev_engines\n                        .as_ref()\n                        .and_then(|de| de.runtime.as_ref())\n                        .and_then(|rt| rt.find_by_name(\"node\"))\n                        .map(|r| r.version.clone())\n                        .filter(|v| !v.is_empty())\n                        .and_then(|v| normalize_version(&v, \"devEngines.runtime\"))\n                    {\n                        let resolved = resolve_version_string(&dev_engines, &provider).await?;\n                        let is_range = NodeProvider::is_lts_alias(&dev_engines)\n                            || !NodeProvider::is_exact_version(&dev_engines);\n                        return Ok(VersionResolution {\n                            version: resolved,\n                            source: \"devEngines.runtime\".into(),\n                            source_path: Some(package_json_path),\n                            project_root: Some(project_root.clone()),\n                            is_range,\n                        });\n                    }\n                }\n            }\n        }\n        // Invalid version and no valid package.json sources - fall through to user default or LTS\n    }\n\n    // CLI-specific: Check user default from config\n    let config = load_config().await?;\n    if let Some(default_version) = config.default_node_version {\n        let resolved = resolve_version_alias(&default_version, &provider).await?;\n        // Check if default is an alias or range\n        let is_alias = matches!(default_version.to_lowercase().as_str(), \"lts\" | \"latest\");\n        let is_range = is_alias\n            || NodeProvider::is_lts_alias(&default_version)\n            || !NodeProvider::is_exact_version(&default_version);\n        return Ok(VersionResolution {\n            version: resolved,\n            source: \"default\".into(),\n            // Don't set source_path for aliases (lts, latest) so cache can refresh\n            source_path: if is_alias { None } else { Some(get_config_path()?) },\n            project_root: None,\n            is_range,\n        });\n    }\n\n    // CLI-specific: Fall back to latest LTS\n    let version = provider.resolve_latest_version().await?;\n    Ok(VersionResolution {\n        version: version.to_string(),\n        source: \"lts\".into(),\n        source_path: None,\n        project_root: None,\n        is_range: true, // LTS fallback is always a range (re-resolve periodically)\n    })\n}\n\n/// Resolve a version string to an exact version.\nasync fn resolve_version_string(version: &str, provider: &NodeProvider) -> Result<String, Error> {\n    // Check for LTS alias first (lts/*, lts/iron, lts/-1)\n    if NodeProvider::is_lts_alias(version) {\n        let resolved = provider.resolve_lts_alias(version).await?;\n        return Ok(resolved.to_string());\n    }\n\n    // Check for \"latest\" alias - resolves to absolute latest version (including non-LTS)\n    if NodeProvider::is_latest_alias(version) {\n        let resolved = provider.resolve_version(\"*\").await?;\n        return Ok(resolved.to_string());\n    }\n\n    // If it's already an exact version, use it directly\n    if NodeProvider::is_exact_version(version) {\n        // Strip v prefix if present (e.g., \"v20.18.0\" -> \"20.18.0\")\n        let normalized = version.strip_prefix('v').unwrap_or(version);\n        return Ok(normalized.to_string());\n    }\n\n    // Resolve from network (semver ranges)\n    let resolved = provider.resolve_version(version).await?;\n    Ok(resolved.to_string())\n}\n\n/// Resolve version alias (lts, latest) to an exact version.\n///\n/// Wraps resolution errors with a user-friendly message showing valid examples.\npub async fn resolve_version_alias(\n    version: &str,\n    provider: &NodeProvider,\n) -> Result<String, Error> {\n    let result = match version.to_lowercase().as_str() {\n        \"lts\" => {\n            let resolved = provider.resolve_latest_version().await?;\n            Ok(resolved.to_string())\n        }\n        \"latest\" => {\n            // Resolve * to get the absolute latest version\n            let resolved = provider.resolve_version(\"*\").await?;\n            Ok(resolved.to_string())\n        }\n        _ => resolve_version_string(version, provider).await,\n    };\n    result.map_err(|e| match e {\n        Error::RuntimeDownload(\n            vite_js_runtime::Error::SemverRange(_)\n            | vite_js_runtime::Error::NoMatchingVersion { .. },\n        ) => Error::Other(\n            format!(\n                \"Invalid Node.js version: \\\"{version}\\\"\\n\\n\\\n                 Valid examples:\\n  \\\n                 vp env use 20          # Latest Node.js 20.x\\n  \\\n                 vp env use 20.18.0     # Exact version\\n  \\\n                 vp env use lts         # Latest LTS version\\n  \\\n                 vp env use latest      # Latest version\"\n            )\n            .into(),\n        ),\n        other => other,\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n    use vite_js_runtime::VersionSource;\n    use vite_path::AbsolutePathBuf;\n\n    use super::*;\n\n    #[test]\n    fn test_get_node_modules_dir_probes_unix_layout() {\n        let temp_dir = TempDir::new().unwrap();\n        let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create Unix layout\n        let unix_path = temp_dir.path().join(\"lib\").join(\"node_modules\").join(\"test-pkg\");\n        std::fs::create_dir_all(&unix_path).unwrap();\n\n        let result = get_node_modules_dir(&prefix, \"test-pkg\");\n        assert!(\n            result.as_path().ends_with(\"lib/node_modules/test-pkg\"),\n            \"Should find Unix layout: {}\",\n            result.as_path().display()\n        );\n    }\n\n    #[test]\n    fn test_get_node_modules_dir_probes_windows_layout() {\n        let temp_dir = TempDir::new().unwrap();\n        let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create Windows layout (no lib/)\n        let win_path = temp_dir.path().join(\"node_modules\").join(\"test-pkg\");\n        std::fs::create_dir_all(&win_path).unwrap();\n\n        let result = get_node_modules_dir(&prefix, \"test-pkg\");\n        assert!(\n            result.as_path().ends_with(\"node_modules/test-pkg\")\n                && !result.as_path().to_string_lossy().contains(\"lib/node_modules\"),\n            \"Should find Windows layout: {}\",\n            result.as_path().display()\n        );\n    }\n\n    #[test]\n    fn test_get_node_modules_dir_prefers_unix_layout_when_both_exist() {\n        let temp_dir = TempDir::new().unwrap();\n        let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create both layouts\n        let unix_path = temp_dir.path().join(\"lib\").join(\"node_modules\").join(\"test-pkg\");\n        let win_path = temp_dir.path().join(\"node_modules\").join(\"test-pkg\");\n        std::fs::create_dir_all(&unix_path).unwrap();\n        std::fs::create_dir_all(&win_path).unwrap();\n\n        let result = get_node_modules_dir(&prefix, \"test-pkg\");\n        // Unix layout is checked first\n        assert!(\n            result.as_path().ends_with(\"lib/node_modules/test-pkg\"),\n            \"Should prefer Unix layout when both exist: {}\",\n            result.as_path().display()\n        );\n    }\n\n    #[test]\n    fn test_get_node_modules_dir_returns_platform_default_when_neither_exists() {\n        let temp_dir = TempDir::new().unwrap();\n        let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Don't create any directories\n        let result = get_node_modules_dir(&prefix, \"test-pkg\");\n\n        #[cfg(windows)]\n        assert!(\n            result.as_path().ends_with(\"node_modules/test-pkg\")\n                && !result.as_path().to_string_lossy().contains(\"lib/node_modules\"),\n            \"Should return Windows default: {}\",\n            result.as_path().display()\n        );\n\n        #[cfg(not(windows))]\n        assert!(\n            result.as_path().ends_with(\"lib/node_modules/test-pkg\"),\n            \"Should return Unix default: {}\",\n            result.as_path().display()\n        );\n    }\n\n    #[test]\n    fn test_get_node_modules_dir_handles_scoped_packages() {\n        let temp_dir = TempDir::new().unwrap();\n        let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create Unix layout for scoped package\n        let unix_path = temp_dir.path().join(\"lib\").join(\"node_modules\").join(\"@scope\").join(\"pkg\");\n        std::fs::create_dir_all(&unix_path).unwrap();\n\n        let result = get_node_modules_dir(&prefix, \"@scope/pkg\");\n        assert!(\n            result.as_path().ends_with(\"lib/node_modules/@scope/pkg\"),\n            \"Should find scoped package: {}\",\n            result.as_path().display()\n        );\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_from_node_version_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test());\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n        assert!(resolution.source_path.is_some());\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_walks_up_directory() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test());\n\n        // Create .node-version in parent\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // Create subdirectory\n        let subdir = temp_path.join(\"subdir\");\n        tokio::fs::create_dir(&subdir).await.unwrap();\n\n        let resolution = resolve_version(&subdir).await.unwrap();\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_from_engines_node() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with engines.node\n        // Also create an empty .node-version to stop walk-up from finding parent project's version\n        let package_json = r#\"{\"engines\":{\"node\":\"20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Use resolve_node_version directly with walk_up=false to test engines.node specifically\n        let resolution = resolve_node_version(&temp_path, false)\n            .await\n            .map_err(|e| Error::ConfigError(e.to_string().into()))\n            .unwrap()\n            .unwrap();\n\n        assert_eq!(&*resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, VersionSource::EnginesNode);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_from_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with devEngines.runtime\n        let package_json = r#\"{\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"20.18.0\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Use resolve_node_version directly with walk_up=false to test devEngines specifically\n        let resolution = resolve_node_version(&temp_path, false)\n            .await\n            .map_err(|e| Error::ConfigError(e.to_string().into()))\n            .unwrap()\n            .unwrap();\n\n        assert_eq!(&*resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, VersionSource::DevEnginesRuntime);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_node_version_takes_priority() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test());\n\n        // Create both .node-version and package.json with engines.node\n        tokio::fs::write(temp_path.join(\".node-version\"), \"22.0.0\\n\").await.unwrap();\n        let package_json = r#\"{\"engines\":{\"node\":\"20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n        // .node-version should take priority\n        assert_eq!(resolution.version, \"22.0.0\");\n        assert_eq!(resolution.source, \".node-version\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_string_strips_v_prefix() {\n        let provider = NodeProvider::new();\n        // Test that v-prefixed exact versions are normalized\n        let result = resolve_version_string(\"v20.18.0\", &provider).await.unwrap();\n        assert_eq!(result, \"20.18.0\", \"v prefix should be stripped from exact versions\");\n    }\n\n    #[tokio::test]\n    #[ignore] // Requires running outside of any Node.js project (walk-up finds .node-version)\n    async fn test_resolve_version_alias_default_no_source_path() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        let config = Config { default_node_version: Some(\"lts\".to_string()), ..Default::default() };\n        save_config(&config).await.unwrap();\n\n        // Create empty dir to resolve version in (no .node-version)\n        let test_dir = temp_path.join(\"test-project\");\n        tokio::fs::create_dir_all(&test_dir).await.unwrap();\n\n        let resolution = resolve_version(&test_dir).await.unwrap();\n        assert_eq!(resolution.source, \"default\");\n        assert!(resolution.source_path.is_none(), \"Alias defaults should not have source_path\");\n    }\n\n    #[tokio::test]\n    #[ignore] // Requires running outside of any Node.js project (walk-up finds .node-version)\n    async fn test_resolve_version_exact_default_has_source_path() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        let config =\n            Config { default_node_version: Some(\"20.18.0\".to_string()), ..Default::default() };\n        save_config(&config).await.unwrap();\n\n        // Create empty dir to resolve version in (no .node-version)\n        let test_dir = temp_path.join(\"test-project\");\n        tokio::fs::create_dir_all(&test_dir).await.unwrap();\n\n        let resolution = resolve_version(&test_dir).await.unwrap();\n        assert_eq!(resolution.source, \"default\");\n        assert!(resolution.source_path.is_some(), \"Exact version defaults should have source_path\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_invalid_node_version_falls_through_to_lts() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create .node-version file with invalid version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"invalid-version\\n\").await.unwrap();\n\n        // resolve_version should NOT fail - it should fall through to LTS\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Should fall through to LTS since the .node-version is invalid\n        // and no user default is configured\n        assert_eq!(resolution.source, \"lts\");\n        assert!(resolution.source_path.is_none());\n        assert!(resolution.is_range, \"LTS fallback should be marked as range\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_invalid_node_version_falls_through_to_default() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create .node-version file with invalid version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"not-a-version\\n\").await.unwrap();\n\n        // Create config with a default version\n        let config =\n            Config { default_node_version: Some(\"20.18.0\".to_string()), ..Default::default() };\n        save_config(&config).await.unwrap();\n\n        // resolve_version should NOT fail - it should fall through to user default\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Should fall through to user default since .node-version is invalid\n        assert_eq!(resolution.source, \"default\");\n        assert_eq!(resolution.version, \"20.18.0\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_invalid_node_version_falls_through_to_engines_node() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create .node-version file with invalid version (typo or unsupported alias)\n        tokio::fs::write(temp_path.join(\".node-version\"), \"laetst\\n\").await.unwrap();\n\n        // Create package.json with valid engines.node\n        let package_json = r#\"{\"engines\":{\"node\":\"^20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // resolve_version should NOT fail - it should fall through to engines.node\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Should fall through to engines.node since .node-version is invalid\n        assert_eq!(resolution.source, \"engines.node\");\n        // Version should be resolved from ^20.18.0 (a 20.x version)\n        assert!(\n            resolution.version.starts_with(\"20.\"),\n            \"Expected version to start with '20.', got: {}\",\n            resolution.version\n        );\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_invalid_node_version_falls_through_to_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create .node-version file with invalid version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"invalid\\n\").await.unwrap();\n\n        // Create package.json with devEngines.runtime but no engines.node\n        let package_json = r#\"{\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"^20.18.0\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // resolve_version should NOT fail - it should fall through to devEngines.runtime\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Should fall through to devEngines.runtime since .node-version is invalid\n        assert_eq!(resolution.source, \"devEngines.runtime\");\n        // Version should be resolved from ^20.18.0 (a 20.x version)\n        assert!(\n            resolution.version.starts_with(\"20.\"),\n            \"Expected version to start with '20.', got: {}\",\n            resolution.version\n        );\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_invalid_engines_node_falls_through_to_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create package.json with invalid engines.node but valid devEngines.runtime\n        // No .node-version file — resolve_node_version returns EnginesNode source\n        let package_json = r#\"{\"engines\":{\"node\":\"invalid\"},\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"^20.18.0\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // resolve_version should fall through from invalid engines.node to devEngines.runtime\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        assert_eq!(resolution.source, \"devEngines.runtime\");\n        assert!(\n            resolution.version.starts_with(\"20.\"),\n            \"Expected version to start with '20.', got: {}\",\n            resolution.version\n        );\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_latest_alias_in_node_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test());\n\n        // Create .node-version file with \"latest\" alias\n        tokio::fs::write(temp_path.join(\".node-version\"), \"latest\\n\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Should resolve from .node-version\n        assert_eq!(resolution.source, \".node-version\");\n        // \"latest\" is a range (should be re-resolved periodically)\n        assert!(resolution.is_range, \"'latest' should be marked as a range\");\n        // Version should be at least v20.x\n        assert!(\n            resolution.version.starts_with(\"2\") || resolution.version.starts_with(\"3\"),\n            \"Expected version to be at least v20.x, got: {}\",\n            resolution.version\n        );\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_env_var_takes_priority() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"22.0.0\".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // VITE_PLUS_NODE_VERSION should take priority over .node-version\n        assert_eq!(resolution.version, \"22.0.0\");\n        assert_eq!(resolution.source, VERSION_ENV_VAR);\n        assert!(resolution.source_path.is_none());\n        assert!(!resolution.is_range);\n    }\n\n    /// Verify that the env var source is accepted by `vp env install` (no-arg) source validation.\n    /// This is a regression test for a bug where `vp env use 24` followed by `vp env install`\n    /// would fail with \"No Node.js version found in current project.\"\n    #[tokio::test]\n    async fn test_env_var_source_accepted_by_install_validation() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"22.0.0\".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // The install command uses this match to validate sources.\n        // VERSION_ENV_VAR must be accepted alongside project-file sources.\n        let accepted = matches!(\n            resolution.source.as_str(),\n            \".node-version\" | \"engines.node\" | \"devEngines.runtime\" | VERSION_ENV_VAR\n        );\n        assert!(\n            accepted,\n            \"Install source validation should accept '{}' but it was rejected\",\n            resolution.source\n        );\n        assert_eq!(resolution.version, \"22.0.0\");\n    }\n\n    // ── Session version file tests ──\n\n    #[tokio::test]\n    async fn test_write_and_read_session_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Write a session version\n        write_session_version(\"22.0.0\").await.unwrap();\n\n        // Read it back (async)\n        let version = read_session_version().await;\n        assert_eq!(version.as_deref(), Some(\"22.0.0\"));\n\n        // Read it back (sync)\n        let version_sync = read_session_version_sync();\n        assert_eq!(version_sync.as_deref(), Some(\"22.0.0\"));\n    }\n\n    #[tokio::test]\n    async fn test_read_session_version_returns_none_when_missing() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        assert!(read_session_version().await.is_none());\n        assert!(read_session_version_sync().is_none());\n    }\n\n    #[tokio::test]\n    async fn test_read_session_version_returns_none_for_empty_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Write empty content\n        let path = get_session_version_path().unwrap();\n        tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap();\n        tokio::fs::write(&path, \"\").await.unwrap();\n\n        assert!(read_session_version().await.is_none());\n        assert!(read_session_version_sync().is_none());\n\n        // Also test whitespace-only content\n        tokio::fs::write(&path, \"   \\n  \").await.unwrap();\n        assert!(read_session_version().await.is_none());\n        assert!(read_session_version_sync().is_none());\n    }\n\n    #[tokio::test]\n    async fn test_read_session_version_trims_whitespace() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        write_session_version(\"20.18.0\").await.unwrap();\n\n        // Overwrite with whitespace-padded content\n        let path = get_session_version_path().unwrap();\n        tokio::fs::write(&path, \"  20.18.0  \\n\").await.unwrap();\n\n        assert_eq!(read_session_version().await.as_deref(), Some(\"20.18.0\"));\n        assert_eq!(read_session_version_sync().as_deref(), Some(\"20.18.0\"));\n    }\n\n    #[tokio::test]\n    async fn test_delete_session_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Write then delete\n        write_session_version(\"22.0.0\").await.unwrap();\n        assert!(read_session_version().await.is_some());\n\n        delete_session_version().await.unwrap();\n        assert!(read_session_version().await.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_delete_session_version_ignores_missing_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Deleting a non-existent file should succeed\n        let result = delete_session_version().await;\n        assert!(result.is_ok());\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_session_file_takes_priority_over_node_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // Write session version file\n        write_session_version(\"22.0.0\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Session file should take priority over .node-version\n        assert_eq!(resolution.version, \"22.0.0\");\n        assert_eq!(resolution.source, SESSION_VERSION_FILE);\n        assert!(resolution.source_path.is_some());\n        assert!(!resolution.is_range);\n\n        // Clean up\n        delete_session_version().await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_env_var_takes_priority_over_session_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"24.0.0\".into()),\n            vite_plus_home: Some(temp_dir.path().into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        // Write session version file with different version\n        write_session_version(\"22.0.0\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Env var should take priority over session file\n        assert_eq!(resolution.version, \"24.0.0\");\n        assert_eq!(resolution.source, VERSION_ENV_VAR);\n\n        // Clean up\n        delete_session_version().await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_falls_through_when_no_session_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Should fall through to .node-version since no session file exists\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n    }\n\n    /// Verify that the session file source is accepted by `vp env install` (no-arg) source validation.\n    /// This is a regression test ensuring `vp env use 24` followed by `vp env install`\n    /// works when the session file is the resolution source.\n    #[tokio::test]\n    async fn test_session_file_source_accepted_by_install_validation() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Write session version file\n        write_session_version(\"22.0.0\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // The install command uses this match to validate sources.\n        // SESSION_VERSION_FILE must be accepted alongside project-file sources.\n        let accepted = matches!(\n            resolution.source.as_str(),\n            \".node-version\"\n                | \"engines.node\"\n                | \"devEngines.runtime\"\n                | VERSION_ENV_VAR\n                | SESSION_VERSION_FILE\n        );\n        assert!(\n            accepted,\n            \"Install source validation should accept '{}' but it was rejected\",\n            resolution.source\n        );\n        assert_eq!(resolution.version, \"22.0.0\");\n        assert_eq!(resolution.source, SESSION_VERSION_FILE);\n\n        // Clean up\n        delete_session_version().await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_empty_env_var_is_ignored() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"\".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Empty env var should be ignored, should fall through to .node-version\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_whitespace_env_var_is_ignored() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"   \".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let resolution = resolve_version(&temp_path).await.unwrap();\n\n        // Whitespace env var should be ignored, should fall through to .node-version\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n    }\n\n    // ── resolve_version_from_files tests ──\n\n    /// Verify that `resolve_version_from_files` ignores session env var override.\n    /// This is the key behavior for `vp env use` without arguments.\n    #[tokio::test]\n    async fn test_resolve_version_from_files_ignores_env_var() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"22.0.0\".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        // Create .node-version file with different version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // resolve_version_from_files should skip env var and use .node-version\n        let resolution = resolve_version_from_files(&temp_path).await.unwrap();\n\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n    }\n\n    /// Verify that `resolve_version_from_files` ignores session file override.\n    #[tokio::test]\n    async fn test_resolve_version_from_files_ignores_session_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),\n        );\n\n        // Write session version file\n        write_session_version(\"22.0.0\").await.unwrap();\n\n        // Create .node-version file with different version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // resolve_version_from_files should skip session file and use .node-version\n        let resolution = resolve_version_from_files(&temp_path).await.unwrap();\n\n        assert_eq!(resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, \".node-version\");\n\n        // Clean up\n        delete_session_version().await.unwrap();\n    }\n\n    /// Verify that `resolve_version_from_files` still respects both env var and session file.\n    #[tokio::test]\n    async fn test_resolve_version_still_respects_overrides() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            node_version: Some(\"22.0.0\".into()),\n            ..vite_shared::EnvConfig::for_test_with_home(temp_dir.path())\n        });\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // resolve_version should still use env var (existing behavior)\n        let resolution = resolve_version(&temp_path).await.unwrap();\n        assert_eq!(resolution.version, \"22.0.0\");\n        assert_eq!(resolution.source, VERSION_ENV_VAR);\n\n        // But resolve_version_from_files should skip it\n        let resolution_from_files = resolve_version_from_files(&temp_path).await.unwrap();\n        assert_eq!(resolution_from_files.version, \"20.18.0\");\n        assert_eq!(resolution_from_files.source, \".node-version\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/current.rs",
    "content": "//! Current environment information command.\n//!\n//! Shows information about the current Node.js environment.\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\nuse serde::Serialize;\nuse vite_path::AbsolutePathBuf;\n\nuse super::config::resolve_version;\nuse crate::{error::Error, help};\n\n/// JSON output structure for `vp env current --json`\n#[derive(Serialize)]\nstruct CurrentEnvInfo {\n    version: String,\n    source: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    project_root: Option<String>,\n    node_path: String,\n    tool_paths: ToolPaths,\n}\n\n#[derive(Serialize)]\nstruct ToolPaths {\n    node: String,\n    npm: String,\n    npx: String,\n}\n\nfn accent(text: &str) -> String {\n    if help::should_style_help() { text.bright_blue().to_string() } else { text.to_string() }\n}\n\nfn print_rows(title: &str, rows: &[(&str, String)]) {\n    println!(\"{}\", help::render_heading(title));\n    let label_width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0);\n    for (label, value) in rows {\n        let padding = \" \".repeat(label_width.saturating_sub(label.chars().count()));\n        println!(\"  {}{}  {value}\", accent(label), padding);\n    }\n}\n\n/// Execute the current command.\npub async fn execute(cwd: AbsolutePathBuf, json: bool) -> Result<ExitStatus, Error> {\n    let resolution = resolve_version(&cwd).await?;\n\n    // Get the home directory for this version\n    let home_dir = vite_shared::get_vite_plus_home()?\n        .join(\"js_runtime\")\n        .join(\"node\")\n        .join(&resolution.version);\n\n    #[cfg(windows)]\n    let (node_path, npm_path, npx_path) =\n        { (home_dir.join(\"node.exe\"), home_dir.join(\"npm.cmd\"), home_dir.join(\"npx.cmd\")) };\n\n    #[cfg(not(windows))]\n    let (node_path, npm_path, npx_path) = {\n        (\n            home_dir.join(\"bin\").join(\"node\"),\n            home_dir.join(\"bin\").join(\"npm\"),\n            home_dir.join(\"bin\").join(\"npx\"),\n        )\n    };\n\n    if json {\n        let info = CurrentEnvInfo {\n            version: resolution.version.clone(),\n            source: resolution.source.clone(),\n            project_root: resolution\n                .project_root\n                .as_ref()\n                .map(|p| p.as_path().display().to_string()),\n            node_path: node_path.as_path().display().to_string(),\n            tool_paths: ToolPaths {\n                node: node_path.as_path().display().to_string(),\n                npm: npm_path.as_path().display().to_string(),\n                npx: npx_path.as_path().display().to_string(),\n            },\n        };\n\n        let json_str = serde_json::to_string_pretty(&info)?;\n        println!(\"{json_str}\");\n    } else {\n        let mut environment_rows =\n            vec![(\"Version\", resolution.version.clone()), (\"Source\", resolution.source.clone())];\n        if let Some(path) = &resolution.source_path {\n            environment_rows.push((\"Source Path\", path.as_path().display().to_string()));\n        }\n        if let Some(root) = &resolution.project_root {\n            environment_rows.push((\"Project Root\", root.as_path().display().to_string()));\n        }\n\n        print_rows(\"Environment\", &environment_rows);\n        println!();\n        print_rows(\n            \"Tool Paths\",\n            &[\n                (\"node\", node_path.as_path().display().to_string()),\n                (\"npm\", npm_path.as_path().display().to_string()),\n                (\"npx\", npx_path.as_path().display().to_string()),\n            ],\n        );\n    }\n\n    Ok(ExitStatus::default())\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/default.rs",
    "content": "//! Default version management command.\n//!\n//! Handles `vp env default [VERSION]` to set or show the global default Node.js version.\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse super::config::{get_config_path, load_config, save_config};\nuse crate::error::Error;\n\n/// Execute the default command.\npub async fn execute(_cwd: AbsolutePathBuf, version: Option<String>) -> Result<ExitStatus, Error> {\n    match version {\n        Some(v) => set_default(&v).await,\n        None => show_default().await,\n    }\n}\n\n/// Show the current default version.\nasync fn show_default() -> Result<ExitStatus, Error> {\n    let config = load_config().await?;\n\n    match config.default_node_version {\n        Some(version) => {\n            println!(\"Default Node.js version: {version}\");\n            let config_path = get_config_path()?;\n            println!(\"  Set via: {}\", config_path.as_path().display());\n\n            // If it's an alias, also show the resolved version\n            if version == \"lts\" || version == \"latest\" {\n                let provider = vite_js_runtime::NodeProvider::new();\n                match resolve_alias(&version, &provider).await {\n                    Ok(resolved) => println!(\"  Currently resolves to: {resolved}\"),\n                    Err(_) => {}\n                }\n            }\n        }\n        None => {\n            // No default configured - show what would be used\n            let provider = vite_js_runtime::NodeProvider::new();\n            match provider.resolve_latest_version().await {\n                Ok(lts_version) => {\n                    println!(\"No default version configured. Using latest LTS ({lts_version}).\");\n                    println!(\"  Run 'vp env default <version>' to set a default.\");\n                }\n                Err(_) => {\n                    println!(\"No default version configured.\");\n                    println!(\"  Run 'vp env default <version>' to set a default.\");\n                }\n            }\n        }\n    }\n\n    Ok(ExitStatus::default())\n}\n\n/// Set the default version.\nasync fn set_default(version: &str) -> Result<ExitStatus, Error> {\n    let provider = vite_js_runtime::NodeProvider::new();\n\n    // Validate the version\n    let (display_version, store_version) = match version.to_lowercase().as_str() {\n        \"lts\" => {\n            // Resolve to show current value, but store \"lts\" as alias\n            let current_lts = provider.resolve_latest_version().await?;\n            (format!(\"lts (currently {})\", current_lts), \"lts\".to_string())\n        }\n        \"latest\" => {\n            // Resolve to show current value, but store \"latest\" as alias\n            let current_latest = provider.resolve_version(\"*\").await?;\n            (format!(\"latest (currently {})\", current_latest), \"latest\".to_string())\n        }\n        _ => {\n            // Validate version exists\n            let resolved = if vite_js_runtime::NodeProvider::is_exact_version(version) {\n                version.to_string()\n            } else {\n                provider.resolve_version(version).await?.to_string()\n            };\n            (resolved.clone(), resolved)\n        }\n    };\n\n    // Save to config\n    let mut config = load_config().await?;\n    config.default_node_version = Some(store_version);\n    save_config(&config).await?;\n\n    // Invalidate resolve cache so the new default takes effect immediately\n    crate::shim::invalidate_cache();\n\n    println!(\"\\u{2713} Default Node.js version set to {display_version}\");\n\n    Ok(ExitStatus::default())\n}\n\n/// Resolve version alias to actual version.\nasync fn resolve_alias(\n    alias: &str,\n    provider: &vite_js_runtime::NodeProvider,\n) -> Result<String, Error> {\n    match alias {\n        \"lts\" => Ok(provider.resolve_latest_version().await?.to_string()),\n        \"latest\" => Ok(provider.resolve_version(\"*\").await?.to_string()),\n        _ => Ok(alias.to_string()),\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/doctor.rs",
    "content": "//! Doctor command implementation for environment diagnostics.\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\nuse vite_path::{AbsolutePathBuf, current_dir};\nuse vite_shared::{env_vars, output};\n\nuse super::config::{\n    self, ShimMode, get_bin_dir, get_vite_plus_home, load_config, resolve_version,\n};\nuse crate::error::Error;\n\n/// IDE-relevant profile files that GUI-launched applications can see.\n/// GUI apps don't run through an interactive shell, so only login/environment\n/// files reliably affect them.\n/// - macOS: `.zshenv` is sourced for all zsh invocations (including IDE env resolution)\n/// - Linux: `.profile` is sourced by X11 display managers; `.zshenv` covers Wayland + zsh\n#[cfg(not(windows))]\n#[cfg(target_os = \"macos\")]\nconst IDE_PROFILES: &[(&str, bool)] = &[(\".zshenv\", false), (\".profile\", false)];\n\n#[cfg(not(windows))]\n#[cfg(target_os = \"linux\")]\nconst IDE_PROFILES: &[(&str, bool)] = &[(\".profile\", false), (\".zshenv\", false)];\n\n#[cfg(not(windows))]\n#[cfg(not(any(target_os = \"macos\", target_os = \"linux\")))]\nconst IDE_PROFILES: &[(&str, bool)] = &[(\".profile\", false)];\n\n/// All shell profile files that interactive terminal sessions may source.\n/// This matches the files that `install.sh` writes to and `vp implode` cleans.\n/// The bool flag indicates whether the file uses fish-style sourcing (`env.fish`\n/// instead of `env`).\n#[cfg(not(windows))]\nconst ALL_SHELL_PROFILES: &[(&str, bool)] = &[\n    (\".zshenv\", false),\n    (\".zshrc\", false),\n    (\".bash_profile\", false),\n    (\".bashrc\", false),\n    (\".profile\", false),\n    (\".config/fish/config.fish\", true),\n    (\".config/fish/conf.d/vite-plus.fish\", true),\n];\n\n/// Result of checking profile files for env sourcing.\n#[cfg(not(windows))]\nenum EnvSourcingStatus {\n    /// Found in an IDE-relevant profile (e.g., .zshenv, .profile).\n    IdeFound,\n    /// Found only in an interactive shell profile (e.g., .bashrc, .zshrc).\n    ShellOnly,\n    /// Not found in any profile.\n    NotFound,\n}\n\n/// Known version managers that might conflict\nconst KNOWN_VERSION_MANAGERS: &[(&str, &str)] = &[\n    (\"nvm\", \"NVM_DIR\"),\n    (\"fnm\", \"FNM_DIR\"),\n    (\"volta\", \"VOLTA_HOME\"),\n    (\"asdf\", \"ASDF_DIR\"),\n    (\"mise\", \"MISE_DIR\"),\n    (\"n\", \"N_PREFIX\"),\n];\n\n/// Tools that should have shims\nconst SHIM_TOOLS: &[&str] = &[\"node\", \"npm\", \"npx\", \"vpx\"];\n\n/// Column width for left-side keys in aligned output\nconst KEY_WIDTH: usize = 18;\n\n/// Print a section header (bold, with blank line before).\nfn print_section(name: &str) {\n    println!();\n    println!(\"{}\", name.bold());\n}\n\n/// Print an aligned key-value line with a status indicator.\n///\n/// `status` should be a colored string like \"✓\".green(), \"✗\".red(), etc.\n/// Use `\" \"` for informational lines with no status.\nfn print_check(status: &str, key: &str, value: &str) {\n    if status.trim().is_empty() {\n        println!(\"  {key:<KEY_WIDTH$}{value}\");\n    } else if key.trim().is_empty() {\n        println!(\"  {status} {value}\");\n    } else {\n        println!(\"  {status} {key:<KEY_WIDTH$}{value}\");\n    }\n}\n\n/// Print a continuation/hint line (dimmed).\nfn print_hint(text: &str) {\n    println!(\"  {}\", format!(\"note: {text}\").dimmed());\n}\n\n/// Abbreviate home directory to `~` for display.\nfn abbreviate_home(path: &str) -> String {\n    if let Ok(home) = std::env::var(\"HOME\") {\n        if let Some(suffix) = path.strip_prefix(&home) {\n            return format!(\"~{suffix}\");\n        }\n    }\n    path.to_string()\n}\n\n/// Execute the doctor command.\npub async fn execute(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    let mut has_errors = false;\n\n    // Section: Installation\n    println!(\"{}\", \"Installation\".bold());\n    has_errors |= !check_vite_plus_home().await;\n    has_errors |= !check_bin_dir().await;\n\n    // Section: Configuration\n    print_section(\"Configuration\");\n    check_shim_mode().await;\n\n    // Check env sourcing: IDE-relevant profiles first, then all shell profiles\n    #[cfg(not(windows))]\n    let env_status = check_env_sourcing();\n\n    check_session_override();\n\n    // Section: PATH\n    print_section(\"PATH\");\n    has_errors |= !check_path().await;\n\n    // Section: Version Resolution\n    print_section(\"Version Resolution\");\n    check_current_resolution(&cwd).await;\n\n    // Section: Conflicts (conditional)\n    check_conflicts();\n\n    // Section: IDE Setup (conditional - when env not found in IDE-relevant profiles)\n    #[cfg(not(windows))]\n    {\n        match &env_status {\n            EnvSourcingStatus::IdeFound => {} // All good, no guidance needed\n            EnvSourcingStatus::ShellOnly | EnvSourcingStatus::NotFound => {\n                // Show IDE setup guidance when env is not in IDE-relevant profiles\n                if let Ok(bin_dir) = get_bin_dir() {\n                    print_ide_setup_guidance(&bin_dir);\n                }\n            }\n        }\n    }\n\n    // Summary\n    println!();\n    if has_errors {\n        println!(\n            \"{}\",\n            \"\\u{2717} Some issues found. Run the suggested commands to fix them.\".red().bold()\n        );\n        Ok(super::exit_status(1))\n    } else {\n        println!(\"{}\", \"\\u{2713} All checks passed\".green().bold());\n        Ok(ExitStatus::default())\n    }\n}\n\n/// Check VITE_PLUS_HOME directory.\nasync fn check_vite_plus_home() -> bool {\n    let home = match get_vite_plus_home() {\n        Ok(h) => h,\n        Err(e) => {\n            print_check(\n                &output::CROSS.red().to_string(),\n                env_vars::VITE_PLUS_HOME,\n                &format!(\"{e}\").red().to_string(),\n            );\n            return false;\n        }\n    };\n\n    let display = abbreviate_home(&home.as_path().display().to_string());\n\n    if tokio::fs::try_exists(&home).await.unwrap_or(false) {\n        print_check(&output::CHECK.green().to_string(), env_vars::VITE_PLUS_HOME, &display);\n        true\n    } else {\n        print_check(\n            &output::CROSS.red().to_string(),\n            env_vars::VITE_PLUS_HOME,\n            &\"does not exist\".red().to_string(),\n        );\n        print_hint(\"Run 'vp env setup' to create it.\");\n        false\n    }\n}\n\n/// Check bin directory and shim files.\nasync fn check_bin_dir() -> bool {\n    let bin_dir = match get_bin_dir() {\n        Ok(d) => d,\n        Err(_) => return false,\n    };\n\n    if !tokio::fs::try_exists(&bin_dir).await.unwrap_or(false) {\n        print_check(\n            &output::CROSS.red().to_string(),\n            \"Bin directory\",\n            &\"does not exist\".red().to_string(),\n        );\n        print_hint(\"Run 'vp env setup' to create bin directory and shims.\");\n        return false;\n    }\n\n    print_check(&output::CHECK.green().to_string(), \"Bin directory\", \"exists\");\n\n    let mut missing = Vec::new();\n\n    for tool in SHIM_TOOLS {\n        let shim_path = bin_dir.join(shim_filename(tool));\n        if !tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {\n            missing.push(*tool);\n        }\n    }\n\n    if missing.is_empty() {\n        print_check(&output::CHECK.green().to_string(), \"Shims\", &SHIM_TOOLS.join(\", \"));\n        true\n    } else {\n        print_check(\n            &output::CROSS.red().to_string(),\n            \"Missing shims\",\n            &missing.join(\", \").red().to_string(),\n        );\n        print_hint(\"Run 'vp env setup' to create missing shims.\");\n        false\n    }\n}\n\n/// Get the filename for a shim (platform-specific).\nfn shim_filename(tool: &str) -> String {\n    #[cfg(windows)]\n    {\n        // All tools use trampoline .exe files on Windows\n        format!(\"{tool}.exe\")\n    }\n\n    #[cfg(not(windows))]\n    {\n        tool.to_string()\n    }\n}\n\n/// Check and display shim mode.\nasync fn check_shim_mode() {\n    let config = match load_config().await {\n        Ok(c) => c,\n        Err(e) => {\n            print_check(\n                &output::WARN_SIGN.yellow().to_string(),\n                \"Shim mode\",\n                &format!(\"config error: {e}\").yellow().to_string(),\n            );\n            return;\n        }\n    };\n\n    match config.shim_mode {\n        ShimMode::Managed => {\n            print_check(&output::CHECK.green().to_string(), \"Shim mode\", \"managed\");\n        }\n        ShimMode::SystemFirst => {\n            print_check(\n                &output::CHECK.green().to_string(),\n                \"Shim mode\",\n                &\"system-first\".bright_blue().to_string(),\n            );\n\n            // Check if system Node.js is available\n            if let Some(system_node) = find_system_node() {\n                print_check(\" \", \"System Node.js\", &system_node.display().to_string());\n            } else {\n                print_check(\n                    &output::WARN_SIGN.yellow().to_string(),\n                    \"System Node.js\",\n                    &\"not found (will use managed)\".yellow().to_string(),\n                );\n            }\n        }\n    }\n}\n\n/// Check profile files for env sourcing and classify where it was found.\n///\n/// Tries IDE-relevant profiles first, then falls back to all shell profiles.\n/// Returns `EnvSourcingStatus` indicating where (if anywhere) the sourcing was found.\n#[cfg(not(windows))]\nfn check_env_sourcing() -> EnvSourcingStatus {\n    let bin_dir = match get_bin_dir() {\n        Ok(d) => d,\n        Err(_) => return EnvSourcingStatus::NotFound,\n    };\n\n    let home_path = bin_dir\n        .parent()\n        .map(|p| p.as_path().display().to_string())\n        .unwrap_or_else(|| bin_dir.as_path().display().to_string());\n    let home_path = if let Ok(home_dir) = std::env::var(\"HOME\") {\n        if let Some(suffix) = home_path.strip_prefix(&home_dir) {\n            format!(\"$HOME{suffix}\")\n        } else {\n            home_path\n        }\n    } else {\n        home_path\n    };\n\n    // First: check IDE-relevant profiles (login/environment files visible to GUI apps)\n    if let Some(file) = check_profile_files(&home_path, IDE_PROFILES) {\n        print_check(\n            &output::CHECK.green().to_string(),\n            \"IDE integration\",\n            &format!(\"env sourced in {file}\"),\n        );\n        return EnvSourcingStatus::IdeFound;\n    }\n\n    // Second: check all shell profiles (interactive terminal sessions)\n    if let Some(file) = check_profile_files(&home_path, ALL_SHELL_PROFILES) {\n        print_check(\n            &output::WARN_SIGN.yellow().to_string(),\n            \"IDE integration\",\n            &format!(\n                \"{} {}\",\n                format!(\"env sourced in {file}\").yellow(),\n                \"(may not be visible to GUI apps)\".dimmed(),\n            ),\n        );\n        return EnvSourcingStatus::ShellOnly;\n    }\n\n    EnvSourcingStatus::NotFound\n}\n\n/// Find system Node.js, skipping vite-plus bin directory and any\n/// directories listed in `VITE_PLUS_BYPASS`.\nfn find_system_node() -> Option<std::path::PathBuf> {\n    let bin_dir = get_bin_dir().ok();\n    let path_var = std::env::var_os(\"PATH\")?;\n\n    // Parse VITE_PLUS_BYPASS as a PATH-style list of additional directories to skip\n    let bypass_paths: Vec<std::path::PathBuf> = std::env::var_os(env_vars::VITE_PLUS_BYPASS)\n        .map(|v| std::env::split_paths(&v).collect())\n        .unwrap_or_default();\n\n    // Filter PATH to exclude our bin directory and any bypass directories\n    let filtered_paths: Vec<_> = std::env::split_paths(&path_var)\n        .filter(|p| {\n            if let Some(ref bin) = bin_dir {\n                if p == bin.as_path() {\n                    return false;\n                }\n            }\n            !bypass_paths.iter().any(|bp| p == bp)\n        })\n        .collect();\n\n    let filtered_path = std::env::join_paths(filtered_paths).ok()?;\n\n    // Use vite_command::resolve_bin with filtered PATH - stops at first match\n    let cwd = current_dir().ok()?;\n    vite_command::resolve_bin(\"node\", Some(&filtered_path), &cwd).ok().map(|p| p.into_path_buf())\n}\n\n/// Check for active session override via VITE_PLUS_NODE_VERSION or session file.\nfn check_session_override() {\n    if let Ok(version) = std::env::var(config::VERSION_ENV_VAR) {\n        let version = version.trim();\n        if !version.is_empty() {\n            print_check(\n                &output::WARN_SIGN.yellow().to_string(),\n                \"Session override\",\n                &format!(\"{}={version}\", env_vars::VITE_PLUS_NODE_VERSION).yellow().to_string(),\n            );\n            print_hint(\"Overrides all file-based resolution.\");\n            print_hint(\"Run 'vp env use --unset' to remove.\");\n        }\n    }\n\n    // Also check session version file\n    if let Some(version) = config::read_session_version_sync() {\n        print_check(\n            &output::WARN_SIGN.yellow().to_string(),\n            \"Session override (file)\",\n            &format!(\"{}={version}\", config::SESSION_VERSION_FILE).yellow().to_string(),\n        );\n        print_hint(\"Written by 'vp env use'. Run 'vp env use --unset' to remove.\");\n    }\n}\n\n/// Check PATH configuration.\nasync fn check_path() -> bool {\n    let bin_dir = match get_bin_dir() {\n        Ok(d) => d,\n        Err(_) => return false,\n    };\n\n    let path_var = std::env::var_os(\"PATH\").unwrap_or_default();\n    let paths: Vec<_> = std::env::split_paths(&path_var).collect();\n\n    // Check if bin directory is in PATH\n    let bin_path = bin_dir.as_path();\n    let bin_position = paths.iter().position(|p| p == bin_path);\n\n    let bin_display = abbreviate_home(&bin_dir.as_path().display().to_string());\n\n    match bin_position {\n        Some(0) => {\n            print_check(&output::CHECK.green().to_string(), \"vp\", \"first in PATH\");\n        }\n        Some(pos) => {\n            print_check(\n                &output::WARN_SIGN.yellow().to_string(),\n                \"vp\",\n                &format!(\"in PATH at position {pos}\").yellow().to_string(),\n            );\n            print_hint(\"For best results, bin should be first in PATH.\");\n        }\n        None => {\n            print_check(&output::CROSS.red().to_string(), \"vp\", &\"not in PATH\".red().to_string());\n            print_hint(&format!(\"Expected: {bin_display}\"));\n            println!();\n            print_path_fix(&bin_dir);\n            return false;\n        }\n    }\n\n    // Show which tool would be executed for each shim\n    for tool in SHIM_TOOLS {\n        if let Some(tool_path) = find_in_path(tool) {\n            let expected = bin_dir.join(shim_filename(tool));\n            let display = abbreviate_home(&tool_path.display().to_string());\n            if tool_path == expected.as_path() {\n                print_check(\n                    &output::CHECK.green().to_string(),\n                    tool,\n                    &format!(\"{display} {}\", \"(vp shim)\".dimmed()),\n                );\n            } else {\n                print_check(\n                    &output::WARN_SIGN.yellow().to_string(),\n                    tool,\n                    &format!(\"{} {}\", display.yellow(), \"(not vp shim)\".dimmed()),\n                );\n            }\n        } else {\n            print_check(\" \", tool, \"not found\");\n        }\n    }\n\n    true\n}\n\n/// Find an executable in PATH.\nfn find_in_path(name: &str) -> Option<std::path::PathBuf> {\n    let cwd = current_dir().ok()?;\n    vite_command::resolve_bin(name, None, &cwd).ok().map(|p| p.into_path_buf())\n}\n\n/// Print PATH fix instructions for shell setup.\nfn print_path_fix(bin_dir: &vite_path::AbsolutePath) {\n    #[cfg(not(windows))]\n    {\n        // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability\n        let home_path = bin_dir\n            .parent()\n            .map(|p| p.as_path().display().to_string())\n            .unwrap_or_else(|| bin_dir.as_path().display().to_string());\n        let home_path = if let Ok(home_dir) = std::env::var(\"HOME\") {\n            if let Some(suffix) = home_path.strip_prefix(&home_dir) {\n                format!(\"$HOME{suffix}\")\n            } else {\n                home_path\n            }\n        } else {\n            home_path\n        };\n\n        println!(\"  {}\", \"Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):\".dimmed());\n        println!();\n        println!(\"  . \\\"{home_path}/env\\\"\");\n        println!();\n        println!(\"  {}\", \"For fish shell, add to ~/.config/fish/config.fish:\".dimmed());\n        println!();\n        println!(\"  source \\\"{home_path}/env.fish\\\"\");\n        println!();\n        println!(\"  {}\", \"Then restart your terminal.\".dimmed());\n    }\n\n    #[cfg(windows)]\n    {\n        let _ = bin_dir;\n        println!(\"  {}\", \"Add the bin directory to your PATH via:\".dimmed());\n        println!(\"  System Properties -> Environment Variables -> Path\");\n        println!();\n        println!(\"  {}\", \"Then restart your terminal.\".dimmed());\n    }\n}\n\n/// Search for vite-plus env sourcing line in the given profile files.\n///\n/// Each entry in `profile_files` is `(filename, is_fish)`. When `is_fish` is true,\n/// searches for the `env.fish` pattern instead of `env`.\n///\n/// Returns `Some(display_path)` if any profile file contains a reference\n/// to the vite-plus env file, `None` otherwise.\n#[cfg(not(windows))]\nfn check_profile_files(vite_plus_home: &str, profile_files: &[(&str, bool)]) -> Option<String> {\n    let home_dir = std::env::var(\"HOME\").ok()?;\n\n    for &(file, is_fish) in profile_files {\n        let full_path = format!(\"{home_dir}/{file}\");\n        if let Ok(content) = std::fs::read_to_string(&full_path) {\n            // Build candidate strings: both $HOME/... and /absolute/...\n            let env_suffix = if is_fish { \"/env.fish\" } else { \"/env\" };\n            let mut search_strings = vec![format!(\"{vite_plus_home}{env_suffix}\")];\n            if let Some(suffix) = vite_plus_home.strip_prefix(\"$HOME\") {\n                search_strings.push(format!(\"{home_dir}{suffix}{env_suffix}\"));\n            }\n\n            if search_strings.iter().any(|s| content.contains(s)) {\n                return Some(format!(\"~/{file}\"));\n            }\n        }\n    }\n\n    // If ZDOTDIR is set and differs from $HOME, also check $ZDOTDIR/.zshenv and .zshrc\n    if let Ok(zdotdir) = std::env::var(\"ZDOTDIR\") {\n        if !zdotdir.is_empty() && zdotdir != home_dir {\n            let env_suffix = \"/env\";\n            let mut search_strings = vec![format!(\"{vite_plus_home}{env_suffix}\")];\n            if let Some(suffix) = vite_plus_home.strip_prefix(\"$HOME\") {\n                search_strings.push(format!(\"{home_dir}{suffix}{env_suffix}\"));\n            }\n\n            for file in [\".zshenv\", \".zshrc\"] {\n                let path = format!(\"{zdotdir}/{file}\");\n                if let Ok(content) = std::fs::read_to_string(&path) {\n                    if search_strings.iter().any(|s| content.contains(s)) {\n                        return Some(abbreviate_home(&path));\n                    }\n                }\n            }\n        }\n    }\n\n    // If XDG_CONFIG_HOME is set and differs from default, also check fish conf.d\n    if let Ok(xdg_config) = std::env::var(\"XDG_CONFIG_HOME\") {\n        let default_config = format!(\"{home_dir}/.config\");\n        if !xdg_config.is_empty() && xdg_config != default_config {\n            let fish_suffix = \"/env.fish\";\n            let mut search_strings = vec![format!(\"{vite_plus_home}{fish_suffix}\")];\n            if let Some(suffix) = vite_plus_home.strip_prefix(\"$HOME\") {\n                search_strings.push(format!(\"{home_dir}{suffix}{fish_suffix}\"));\n            }\n\n            let path = format!(\"{xdg_config}/fish/conf.d/vite-plus.fish\");\n            if let Ok(content) = std::fs::read_to_string(&path) {\n                if search_strings.iter().any(|s| content.contains(s)) {\n                    return Some(abbreviate_home(&path));\n                }\n            }\n        }\n    }\n\n    None\n}\n\n/// Print IDE setup guidance for GUI applications.\n#[cfg(not(windows))]\nfn print_ide_setup_guidance(bin_dir: &vite_path::AbsolutePath) {\n    // Derive vite_plus_home display path from bin_dir.parent(), using $HOME prefix\n    let home_path = bin_dir\n        .parent()\n        .map(|p| p.as_path().display().to_string())\n        .unwrap_or_else(|| bin_dir.as_path().display().to_string());\n    let home_path = if let Ok(home_dir) = std::env::var(\"HOME\") {\n        if let Some(suffix) = home_path.strip_prefix(&home_dir) {\n            format!(\"$HOME{suffix}\")\n        } else {\n            home_path\n        }\n    } else {\n        home_path\n    };\n\n    print_section(\"IDE Setup\");\n    print_check(\n        &output::WARN_SIGN.yellow().to_string(),\n        \"\",\n        &\"GUI applications may not see shell PATH changes.\".yellow().to_string(),\n    );\n    println!();\n\n    #[cfg(target_os = \"macos\")]\n    {\n        println!(\"  {}\", \"macOS:\".dimmed());\n        println!(\"  {}\", \"Add to ~/.zshenv or ~/.profile:\".dimmed());\n        println!(\"  . \\\"{home_path}/env\\\"\");\n        println!(\"  {}\", \"Then restart your IDE to apply changes.\".dimmed());\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        println!(\"  {}\", \"Linux:\".dimmed());\n        println!(\"  {}\", \"Add to ~/.profile:\".dimmed());\n        println!(\"  . \\\"{home_path}/env\\\"\");\n        println!(\"  {}\", \"Then log out and log back in for changes to take effect.\".dimmed());\n    }\n\n    // Fallback for other Unix platforms\n    #[cfg(not(any(target_os = \"macos\", target_os = \"linux\")))]\n    {\n        println!(\"  {}\", \"Add to your shell profile:\".dimmed());\n        println!(\"  . \\\"{home_path}/env\\\"\");\n        println!(\"  {}\", \"Then restart your IDE to apply changes.\".dimmed());\n    }\n}\n\n/// Check current directory version resolution.\nasync fn check_current_resolution(cwd: &AbsolutePathBuf) {\n    print_check(\" \", \"Directory\", &cwd.as_path().display().to_string());\n\n    match resolve_version(cwd).await {\n        Ok(resolution) => {\n            let source_display = resolution\n                .source_path\n                .as_ref()\n                .map(|p| p.as_path().display().to_string())\n                .unwrap_or(resolution.source);\n            print_check(\" \", \"Source\", &source_display);\n            print_check(\" \", \"Version\", &resolution.version.bright_green().to_string());\n\n            // Check if Node.js is installed\n            let home_dir = match vite_shared::get_vite_plus_home() {\n                Ok(d) => d.join(\"js_runtime\").join(\"node\").join(&resolution.version),\n                Err(_) => return,\n            };\n\n            #[cfg(windows)]\n            let binary_path = home_dir.join(\"node.exe\");\n            #[cfg(not(windows))]\n            let binary_path = home_dir.join(\"bin\").join(\"node\");\n\n            if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) {\n                print_check(&output::CHECK.green().to_string(), \"Node binary\", \"installed\");\n            } else {\n                print_check(\n                    &output::WARN_SIGN.yellow().to_string(),\n                    \"Node binary\",\n                    &\"not installed\".yellow().to_string(),\n                );\n                print_hint(\"Version will be downloaded on first use.\");\n            }\n        }\n        Err(e) => {\n            print_check(\n                &output::CROSS.red().to_string(),\n                \"Resolution\",\n                &format!(\"failed: {e}\").red().to_string(),\n            );\n        }\n    }\n}\n\n/// Check for conflicts with other version managers.\nfn check_conflicts() {\n    let mut conflicts = Vec::new();\n\n    for (name, env_var) in KNOWN_VERSION_MANAGERS {\n        if std::env::var(env_var).is_ok() {\n            conflicts.push(*name);\n        }\n    }\n\n    // Also check for common shims in PATH\n    if let Some(node_path) = find_in_path(\"node\") {\n        let path_str = node_path.to_string_lossy();\n        if path_str.contains(\".nvm\") {\n            if !conflicts.contains(&\"nvm\") {\n                conflicts.push(\"nvm\");\n            }\n        } else if path_str.contains(\".fnm\") {\n            if !conflicts.contains(&\"fnm\") {\n                conflicts.push(\"fnm\");\n            }\n        } else if path_str.contains(\".volta\") {\n            if !conflicts.contains(&\"volta\") {\n                conflicts.push(\"volta\");\n            }\n        }\n    }\n\n    if !conflicts.is_empty() {\n        print_section(\"Conflicts\");\n        for manager in &conflicts {\n            print_check(\n                &output::WARN_SIGN.yellow().to_string(),\n                manager,\n                &format!(\n                    \"detected ({} is set)\",\n                    KNOWN_VERSION_MANAGERS\n                        .iter()\n                        .find(|(n, _)| n == manager)\n                        .map(|(_, e)| *e)\n                        .unwrap_or(\"in PATH\")\n                )\n                .yellow()\n                .to_string(),\n            );\n        }\n        print_hint(\"Consider removing other version managers from your PATH\");\n        print_hint(\"to avoid version conflicts.\");\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use serial_test::serial;\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[test]\n    fn test_shim_filename_consistency() {\n        // All tools should use the same extension pattern\n        // On Windows: all .cmd, On Unix: all without extension\n        let node = shim_filename(\"node\");\n        let npm = shim_filename(\"npm\");\n        let npx = shim_filename(\"npx\");\n\n        #[cfg(windows)]\n        {\n            // All shims should use .exe on Windows (trampoline executables)\n            assert_eq!(node, \"node.exe\");\n            assert_eq!(npm, \"npm.exe\");\n            assert_eq!(npx, \"npx.exe\");\n        }\n\n        #[cfg(not(windows))]\n        {\n            assert_eq!(node, \"node\");\n            assert_eq!(npm, \"npm\");\n            assert_eq!(npx, \"npx\");\n        }\n    }\n\n    /// Create a fake executable file in the given directory.\n    #[cfg(unix)]\n    fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf {\n        use std::os::unix::fs::PermissionsExt;\n        let path = dir.join(name);\n        std::fs::write(&path, \"#!/bin/sh\\n\").unwrap();\n        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();\n        path\n    }\n\n    #[cfg(windows)]\n    fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf {\n        let path = dir.join(format!(\"{name}.exe\"));\n        std::fs::write(&path, \"fake\").unwrap();\n        path\n    }\n\n    /// Helper to save and restore PATH and VITE_PLUS_BYPASS around a test.\n    struct EnvGuard {\n        original_path: Option<std::ffi::OsString>,\n        original_bypass: Option<std::ffi::OsString>,\n    }\n\n    impl EnvGuard {\n        fn new() -> Self {\n            Self {\n                original_path: std::env::var_os(\"PATH\"),\n                original_bypass: std::env::var_os(env_vars::VITE_PLUS_BYPASS),\n            }\n        }\n    }\n\n    impl Drop for EnvGuard {\n        fn drop(&mut self) {\n            unsafe {\n                match &self.original_path {\n                    Some(v) => std::env::set_var(\"PATH\", v),\n                    None => std::env::remove_var(\"PATH\"),\n                }\n                match &self.original_bypass {\n                    Some(v) => std::env::set_var(env_vars::VITE_PLUS_BYPASS, v),\n                    None => std::env::remove_var(env_vars::VITE_PLUS_BYPASS),\n                }\n            }\n        }\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_system_node_skips_bypass_paths() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let dir_a = temp.path().join(\"bin_a\");\n        let dir_b = temp.path().join(\"bin_b\");\n        std::fs::create_dir_all(&dir_a).unwrap();\n        std::fs::create_dir_all(&dir_b).unwrap();\n        create_fake_executable(&dir_a, \"node\");\n        create_fake_executable(&dir_b, \"node\");\n\n        let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap();\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", &path);\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, dir_a.as_os_str());\n        }\n\n        let result = find_system_node();\n        assert!(result.is_some(), \"Should find node in non-bypassed directory\");\n        assert!(result.unwrap().starts_with(&dir_b), \"Should find node in dir_b, not dir_a\");\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_system_node_returns_none_when_all_paths_bypassed() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let dir_a = temp.path().join(\"bin_a\");\n        std::fs::create_dir_all(&dir_a).unwrap();\n        create_fake_executable(&dir_a, \"node\");\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", dir_a.as_os_str());\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, dir_a.as_os_str());\n        }\n\n        let result = find_system_node();\n        assert!(result.is_none(), \"Should return None when all paths are bypassed\");\n    }\n\n    #[test]\n    fn test_abbreviate_home() {\n        if let Ok(home) = std::env::var(\"HOME\") {\n            let path = format!(\"{home}/.vite-plus\");\n            assert_eq!(abbreviate_home(&path), \"~/.vite-plus\");\n\n            // Non-home path should be unchanged\n            assert_eq!(abbreviate_home(\"/usr/local/bin\"), \"/usr/local/bin\");\n        }\n    }\n\n    /// Guard for env vars used by profile file tests.\n    #[cfg(not(windows))]\n    struct ProfileEnvGuard {\n        original_home: Option<std::ffi::OsString>,\n        original_zdotdir: Option<std::ffi::OsString>,\n        original_xdg_config: Option<std::ffi::OsString>,\n    }\n\n    #[cfg(not(windows))]\n    impl ProfileEnvGuard {\n        fn new(\n            home: &std::path::Path,\n            zdotdir: Option<&std::path::Path>,\n            xdg_config: Option<&std::path::Path>,\n        ) -> Self {\n            let guard = Self {\n                original_home: std::env::var_os(\"HOME\"),\n                original_zdotdir: std::env::var_os(\"ZDOTDIR\"),\n                original_xdg_config: std::env::var_os(\"XDG_CONFIG_HOME\"),\n            };\n            unsafe {\n                std::env::set_var(\"HOME\", home);\n                match zdotdir {\n                    Some(v) => std::env::set_var(\"ZDOTDIR\", v),\n                    None => std::env::remove_var(\"ZDOTDIR\"),\n                }\n                match xdg_config {\n                    Some(v) => std::env::set_var(\"XDG_CONFIG_HOME\", v),\n                    None => std::env::remove_var(\"XDG_CONFIG_HOME\"),\n                }\n            }\n            guard\n        }\n    }\n\n    #[cfg(not(windows))]\n    impl Drop for ProfileEnvGuard {\n        fn drop(&mut self) {\n            unsafe {\n                match &self.original_home {\n                    Some(v) => std::env::set_var(\"HOME\", v),\n                    None => std::env::remove_var(\"HOME\"),\n                }\n                match &self.original_zdotdir {\n                    Some(v) => std::env::set_var(\"ZDOTDIR\", v),\n                    None => std::env::remove_var(\"ZDOTDIR\"),\n                }\n                match &self.original_xdg_config {\n                    Some(v) => std::env::set_var(\"XDG_CONFIG_HOME\", v),\n                    None => std::env::remove_var(\"XDG_CONFIG_HOME\"),\n                }\n            }\n        }\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_check_profile_files_finds_zdotdir() {\n        let temp = TempDir::new().unwrap();\n        let fake_home = temp.path().join(\"home\");\n        let zdotdir = temp.path().join(\"zdotdir\");\n        std::fs::create_dir_all(&fake_home).unwrap();\n        std::fs::create_dir_all(&zdotdir).unwrap();\n\n        std::fs::write(zdotdir.join(\".zshenv\"), \". \\\"$HOME/.vite-plus/env\\\"\\n\").unwrap();\n\n        let _guard = ProfileEnvGuard::new(&fake_home, Some(&zdotdir), None);\n\n        // Pass an empty base list so only ZDOTDIR fallback is triggered\n        let result = check_profile_files(\"$HOME/.vite-plus\", &[]);\n        assert!(result.is_some(), \"Should find .zshenv in ZDOTDIR\");\n        assert!(result.unwrap().ends_with(\".zshenv\"));\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_check_profile_files_finds_xdg_fish() {\n        let temp = TempDir::new().unwrap();\n        let fake_home = temp.path().join(\"home\");\n        let xdg_config = temp.path().join(\"xdg_config\");\n        let fish_dir = xdg_config.join(\"fish/conf.d\");\n        std::fs::create_dir_all(&fake_home).unwrap();\n        std::fs::create_dir_all(&fish_dir).unwrap();\n\n        std::fs::write(fish_dir.join(\"vite-plus.fish\"), \"source \\\"$HOME/.vite-plus/env.fish\\\"\\n\")\n            .unwrap();\n\n        let _guard = ProfileEnvGuard::new(&fake_home, None, Some(&xdg_config));\n\n        // Pass an empty base list so only XDG fallback is triggered\n        let result = check_profile_files(\"$HOME/.vite-plus\", &[]);\n        assert!(result.is_some(), \"Should find vite-plus.fish in XDG_CONFIG_HOME\");\n        assert!(result.unwrap().contains(\"vite-plus.fish\"));\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_check_profile_files_finds_posix_env_in_bashrc() {\n        let temp = TempDir::new().unwrap();\n        let fake_home = temp.path().join(\"home\");\n        std::fs::create_dir_all(&fake_home).unwrap();\n\n        std::fs::write(fake_home.join(\".bashrc\"), \"# some config\\n. \\\"$HOME/.vite-plus/env\\\"\\n\")\n            .unwrap();\n\n        let _guard = ProfileEnvGuard::new(&fake_home, None, None);\n\n        let result =\n            check_profile_files(\"$HOME/.vite-plus\", &[(\".bashrc\", false), (\".profile\", false)]);\n        assert!(result.is_some(), \"Should find env sourcing in .bashrc\");\n        assert_eq!(result.unwrap(), \"~/.bashrc\");\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_check_profile_files_finds_fish_env() {\n        let temp = TempDir::new().unwrap();\n        let fake_home = temp.path().join(\"home\");\n        let fish_dir = fake_home.join(\".config/fish\");\n        std::fs::create_dir_all(&fish_dir).unwrap();\n\n        std::fs::write(fish_dir.join(\"config.fish\"), \"source \\\"$HOME/.vite-plus/env.fish\\\"\\n\")\n            .unwrap();\n\n        let _guard = ProfileEnvGuard::new(&fake_home, None, None);\n\n        let result = check_profile_files(\"$HOME/.vite-plus\", &[(\".config/fish/config.fish\", true)]);\n        assert!(result.is_some(), \"Should find env.fish sourcing in fish config\");\n        assert_eq!(result.unwrap(), \"~/.config/fish/config.fish\");\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_check_profile_files_returns_none_when_not_found() {\n        let temp = TempDir::new().unwrap();\n        let fake_home = temp.path().join(\"home\");\n        std::fs::create_dir_all(&fake_home).unwrap();\n\n        // Create a .bashrc without vite-plus sourcing\n        std::fs::write(fake_home.join(\".bashrc\"), \"# no vite-plus here\\nexport FOO=bar\\n\").unwrap();\n\n        let _guard = ProfileEnvGuard::new(&fake_home, None, None);\n\n        let result =\n            check_profile_files(\"$HOME/.vite-plus\", &[(\".bashrc\", false), (\".profile\", false)]);\n        assert!(result.is_none(), \"Should return None when env sourcing not found\");\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_check_profile_files_finds_absolute_path() {\n        let temp = TempDir::new().unwrap();\n        let fake_home = temp.path().join(\"home\");\n        std::fs::create_dir_all(&fake_home).unwrap();\n\n        // Use absolute path form instead of $HOME\n        let abs_path = format!(\". \\\"{}/home/.vite-plus/env\\\"\\n\", temp.path().display());\n        std::fs::write(fake_home.join(\".zshenv\"), &abs_path).unwrap();\n\n        let _guard = ProfileEnvGuard::new(&fake_home, None, None);\n\n        let result = check_profile_files(\"$HOME/.vite-plus\", &[(\".zshenv\", false)]);\n        assert!(result.is_some(), \"Should find absolute path form of env sourcing\");\n        assert_eq!(result.unwrap(), \"~/.zshenv\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/exec.rs",
    "content": "//! Exec command for executing commands with a specific Node.js version.\n//!\n//! Handles two modes:\n//! 1. Explicit version: `vp env exec --node <version> [--npm <version>] <command>`\n//! 2. Shim mode: `vp env exec <tool> [args...]` where tool is node/npm/npx or a global package binary\n//!\n//! The shim mode uses the same dispatch logic as Unix symlinks, ensuring identical behavior\n//! across platforms (used by Windows .cmd wrappers and Git Bash shell scripts).\n\nuse std::process::ExitStatus;\n\nuse vite_js_runtime::NodeProvider;\nuse vite_shared::{env_vars, format_path_prepended};\n\nuse crate::{\n    error::Error,\n    shim::{dispatch as shim_dispatch, is_shim_tool},\n};\n\n/// Execute the exec command.\n///\n/// When `--node` is provided, runs a command with the specified Node.js version.\n/// When `--node` is not provided and the command is a shim tool (node/npm/npx or global package),\n/// uses the same shim dispatch logic as Unix symlinks.\npub async fn execute(\n    node_version: Option<&str>,\n    npm_version: Option<&str>,\n    command: &[String],\n) -> Result<ExitStatus, Error> {\n    let command = normalize_wrapper_command(command);\n\n    if command.is_empty() {\n        eprintln!(\"vp env exec: missing command to execute\");\n        eprintln!(\"Usage: vp env exec [--node <version>] <command> [args...]\");\n        return Ok(exit_status(1));\n    }\n\n    // If --node is provided, use explicit version mode (existing behavior)\n    if let Some(version) = node_version {\n        return execute_with_version(version, npm_version, &command).await;\n    }\n\n    // No --node provided - check if first command is a shim tool\n    // This includes:\n    // - Core tools (node, npm, npx)\n    // - Globally installed package binaries (tsc, eslint, etc.)\n    let tool = &command[0];\n    if is_shim_tool(tool) {\n        // Clear recursion env var to force fresh version resolution.\n        // This is needed because `vp env exec` may be invoked from within a context\n        // where VITE_PLUS_TOOL_RECURSION is already set (e.g., when pnpm runs through\n        // the vite-plus shim). Without clearing it, shim_dispatch would passthrough\n        // to the system node instead of resolving the version.\n        // SAFETY: This is safe because we're about to spawn a child process and we want\n        // fresh version resolution, not passthrough behavior.\n        unsafe {\n            std::env::remove_var(env_vars::VITE_PLUS_TOOL_RECURSION);\n        }\n\n        // Use the SAME shim dispatch as Unix symlinks - this ensures:\n        // - Core tools: Version resolved from .node-version/package.json/default\n        // - Package binaries: Uses Node.js version from package metadata\n        // - Automatic Node.js download if needed\n        // - Recursion prevention via VITE_PLUS_TOOL_RECURSION\n        // - Shim mode checking (managed vs system-first)\n        let args: Vec<String> = command[1..].to_vec();\n        let exit_code = shim_dispatch(tool, &args).await;\n        return Ok(exit_status(exit_code));\n    }\n\n    // Not a shim tool and no --node - error\n    eprintln!(\"vp env exec: --node is required when running non-shim commands\");\n    eprintln!(\"Usage: vp env exec --node <version> <command> [args...]\");\n    eprintln!();\n    eprintln!(\"For shim tools, --node is optional (version resolved automatically):\");\n    eprintln!(\"  vp env exec node script.js    # Core tool\");\n    eprintln!(\"  vp env exec npm install       # Core tool\");\n    eprintln!(\"  vp env exec tsc --version     # Global package\");\n    Ok(exit_status(1))\n}\n\n/// Normalize arguments when invoked via Windows shim wrappers.\n///\n/// Wrappers insert `--` after the tool name so flags like `--help` aren't\n/// consumed by clap while parsing `vp env exec`. Remove only that inserted\n/// separator before forwarding args to the target tool.\nfn normalize_wrapper_command(command: &[String]) -> Vec<String> {\n    let from_wrapper = std::env::var_os(env_vars::VITE_PLUS_SHIM_WRAPPER).is_some();\n    let normalized = normalize_wrapper_command_inner(command, from_wrapper);\n\n    if from_wrapper {\n        // SAFETY: We're in a short-lived CLI process and clearing a wrapper-only\n        // marker before tool execution avoids leaking it to child processes.\n        unsafe {\n            std::env::remove_var(env_vars::VITE_PLUS_SHIM_WRAPPER);\n        }\n    }\n\n    normalized\n}\n\nfn normalize_wrapper_command_inner(command: &[String], from_wrapper: bool) -> Vec<String> {\n    let mut normalized = command.to_vec();\n    if from_wrapper && normalized.len() >= 2 && normalized[1] == \"--\" {\n        normalized.remove(1);\n    }\n    normalized\n}\n\n/// Execute a command with an explicitly specified Node.js version.\nasync fn execute_with_version(\n    node_version: &str,\n    npm_version: Option<&str>,\n    command: &[String],\n) -> Result<ExitStatus, Error> {\n    // Warn about unsupported --npm flag\n    if npm_version.is_some() {\n        eprintln!(\"Warning: --npm flag is not yet implemented, using bundled npm\");\n    }\n\n    // 1. Resolve version\n    let provider = NodeProvider::new();\n    let resolved_version = resolve_version(node_version, &provider).await?;\n\n    // 2. Ensure installed (download if needed)\n    let runtime =\n        vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &resolved_version)\n            .await?;\n\n    // 3. Clear recursion env var to force re-evaluation in child processes\n    // SAFETY: This is safe because we're about to spawn a child process and we want\n    // to ensure the env var is not inherited. We're not reading this env var in other\n    // threads at this point.\n    unsafe {\n        std::env::remove_var(env_vars::VITE_PLUS_TOOL_RECURSION);\n    }\n\n    // 4. Build PATH with node bin dir first (uses platform-specific separator)\n    // Always prepend to ensure the requested Node version is first in PATH\n    let node_bin_dir = runtime.get_bin_prefix();\n    let new_path = format_path_prepended(node_bin_dir.as_path());\n\n    // 5. Execute command\n    let (cmd, args) = command.split_first().unwrap();\n\n    let status =\n        tokio::process::Command::new(cmd).args(args).env(\"PATH\", new_path).status().await?;\n\n    Ok(status)\n}\n\n/// Resolve version to an exact version.\n///\n/// Handles aliases (lts, latest) and version ranges.\nasync fn resolve_version(version: &str, provider: &NodeProvider) -> Result<String, Error> {\n    match version.to_lowercase().as_str() {\n        \"lts\" => {\n            let resolved = provider.resolve_latest_version().await?;\n            Ok(resolved.to_string())\n        }\n        \"latest\" => {\n            let resolved = provider.resolve_version(\"*\").await?;\n            Ok(resolved.to_string())\n        }\n        _ => {\n            // For exact versions, use directly\n            if NodeProvider::is_exact_version(version) {\n                // Strip v prefix if present\n                let normalized = version.strip_prefix('v').unwrap_or(version);\n                Ok(normalized.to_string())\n            } else {\n                // For ranges/partial versions, resolve to exact\n                let resolved = provider.resolve_version(version).await?;\n                Ok(resolved.to_string())\n            }\n        }\n    }\n}\n\n/// Create an exit status with the given code.\nfn exit_status(code: i32) -> ExitStatus {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        ExitStatus::from_raw(code << 8)\n    }\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::ExitStatusExt;\n        ExitStatus::from_raw(code as u32)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use serial_test::serial;\n\n    use super::*;\n\n    #[tokio::test]\n    async fn test_execute_missing_command() {\n        let result = execute(Some(\"20.18.0\"), None, &[]).await;\n        assert!(result.is_ok());\n        let status = result.unwrap();\n        assert!(!status.success());\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_execute_node_version() {\n        // Run 'node --version' with a specific Node.js version\n        let command = vec![\"node\".to_string(), \"--version\".to_string()];\n        let result = execute(Some(\"20.18.0\"), None, &command).await;\n        assert!(result.is_ok());\n        let status = result.unwrap();\n        assert!(status.success());\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_exact() {\n        let provider = NodeProvider::new();\n        let version = resolve_version(\"20.18.0\", &provider).await.unwrap();\n        assert_eq!(version, \"20.18.0\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_with_v_prefix() {\n        let provider = NodeProvider::new();\n        let version = resolve_version(\"v20.18.0\", &provider).await.unwrap();\n        assert_eq!(version, \"20.18.0\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_partial() {\n        let provider = NodeProvider::new();\n        let version = resolve_version(\"20\", &provider).await.unwrap();\n        // Should resolve to a 20.x.x version - check starts with \"20.\"\n        assert!(version.starts_with(\"20.\"), \"Expected version starting with '20.', got: {version}\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_range() {\n        let provider = NodeProvider::new();\n        let version = resolve_version(\"^20.0.0\", &provider).await.unwrap();\n        // Should resolve to a 20.x.x version - check starts with \"20.\"\n        assert!(version.starts_with(\"20.\"), \"Expected version starting with '20.', got: {version}\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_lts() {\n        let provider = NodeProvider::new();\n        let version = resolve_version(\"lts\", &provider).await.unwrap();\n        // Should resolve to a valid version (format: x.y.z)\n        let parts: Vec<&str> = version.split('.').collect();\n        assert_eq!(parts.len(), 3, \"Expected version format x.y.z, got: {version}\");\n        // Major version should be >= 20 (current LTS line)\n        let major: u32 = parts[0].parse().expect(\"Major version should be a number\");\n        assert!(major >= 20, \"Expected major version >= 20, got: {major}\");\n    }\n\n    #[tokio::test]\n    async fn test_shim_mode_error_for_non_shim_command() {\n        // Running a non-shim command without --node should error\n        let command = vec![\"python\".to_string(), \"--version\".to_string()];\n        let result = execute(None, None, &command).await;\n        assert!(result.is_ok());\n        let status = result.unwrap();\n        // Should fail because python is not a shim tool and --node was not provided\n        assert!(!status.success(), \"Non-shim command without --node should fail\");\n    }\n\n    #[test]\n    fn test_normalize_wrapper_command_strips_only_wrapper_separator() {\n        let command = vec![\"node\".to_string(), \"--\".to_string(), \"--version\".to_string()];\n        let normalized = normalize_wrapper_command_inner(&command, true);\n        assert_eq!(normalized, vec![\"node\", \"--version\"]);\n    }\n\n    #[test]\n    fn test_normalize_wrapper_command_no_wrapper_keeps_separator() {\n        let command = vec![\"node\".to_string(), \"--\".to_string(), \"--version\".to_string()];\n        let normalized = normalize_wrapper_command_inner(&command, false);\n        assert_eq!(normalized, command);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/global_install.rs",
    "content": "//! Global package installation handling.\n\nuse std::{\n    collections::HashSet,\n    io::{Read, Write},\n    process::Stdio,\n};\n\nuse tokio::process::Command;\nuse vite_js_runtime::NodeProvider;\nuse vite_path::{AbsolutePath, current_dir};\nuse vite_shared::{format_path_prepended, output};\n\nuse super::{\n    bin_config::BinConfig,\n    config::{\n        get_bin_dir, get_node_modules_dir, get_packages_dir, get_tmp_dir, resolve_version,\n        resolve_version_alias,\n    },\n    package_metadata::PackageMetadata,\n};\nuse crate::error::Error;\n\n/// Install a global package.\n///\n/// If `node_version` is provided, uses that version. Otherwise, resolves from current directory.\n/// If `force` is true, auto-uninstalls conflicting packages.\npub async fn install(\n    package_spec: &str,\n    node_version: Option<&str>,\n    force: bool,\n) -> Result<(), Error> {\n    // Parse package spec (e.g., \"typescript\", \"typescript@5.0.0\", \"@scope/pkg\")\n    let (package_name, _version_spec) = parse_package_spec(package_spec);\n\n    output::raw(&format!(\"Installing {} globally...\", package_spec));\n\n    // 1. Resolve Node.js version\n    let version = if let Some(v) = node_version {\n        let provider = NodeProvider::new();\n        resolve_version_alias(v, &provider).await?\n    } else {\n        // Resolve from current directory\n        let cwd = current_dir().map_err(|e| {\n            Error::ConfigError(format!(\"Cannot get current directory: {}\", e).into())\n        })?;\n        let resolution = resolve_version(&cwd).await?;\n        resolution.version\n    };\n\n    // 2. Ensure Node.js is installed\n    let runtime =\n        vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &version).await?;\n\n    let node_bin_dir = runtime.get_bin_prefix();\n    let npm_path =\n        if cfg!(windows) { node_bin_dir.join(\"npm.cmd\") } else { node_bin_dir.join(\"npm\") };\n\n    // 3. Create staging directory\n    let tmp_dir = get_tmp_dir()?;\n    let staging_dir = tmp_dir.join(\"packages\").join(&package_name);\n\n    // Clean up any previous failed install\n    if tokio::fs::try_exists(&staging_dir).await.unwrap_or(false) {\n        tokio::fs::remove_dir_all(&staging_dir).await?;\n    }\n    tokio::fs::create_dir_all(&staging_dir).await?;\n\n    // 4. Run npm install with prefix set to staging directory\n    //    Pipe stdout/stderr so npm output is hidden on success, shown on failure\n    let output = Command::new(npm_path.as_path())\n        .args([\"install\", \"-g\", \"--no-fund\", package_spec])\n        .env(\"npm_config_prefix\", staging_dir.as_path())\n        .env(\"PATH\", format_path_prepended(node_bin_dir.as_path()))\n        .stdout(Stdio::piped())\n        .stderr(Stdio::piped())\n        .output()\n        .await?;\n\n    if !output.status.success() {\n        // Clean up staging directory\n        let _ = tokio::fs::remove_dir_all(&staging_dir).await;\n        // Show captured output to help debug the failure\n        let _ = std::io::stdout().write_all(&output.stdout);\n        let _ = std::io::stderr().write_all(&output.stderr);\n        return Err(Error::ConfigError(\n            format!(\"npm install failed with exit code: {:?}\", output.status.code()).into(),\n        ));\n    }\n\n    // 5. Find installed package and extract metadata\n    let node_modules_dir = get_node_modules_dir(&staging_dir, &package_name);\n    let package_json_path = node_modules_dir.join(\"package.json\");\n\n    if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) {\n        let _ = tokio::fs::remove_dir_all(&staging_dir).await;\n        return Err(Error::ConfigError(\n            format!(\n                \"Package {} was not installed correctly, package.json not found at {}\",\n                package_name,\n                package_json_path.as_path().display()\n            )\n            .into(),\n        ));\n    }\n\n    // Read package.json to get version and binaries\n    let package_json_content = tokio::fs::read_to_string(&package_json_path).await?;\n    let package_json: serde_json::Value = serde_json::from_str(&package_json_content)\n        .map_err(|e| Error::ConfigError(format!(\"Failed to parse package.json: {}\", e).into()))?;\n\n    let installed_version = package_json[\"version\"].as_str().unwrap_or(\"unknown\").to_string();\n\n    let binary_infos = extract_binaries(&package_json);\n\n    // Detect which binaries are JavaScript files\n    let mut bin_names = Vec::new();\n    let mut js_bins = HashSet::new();\n    for info in &binary_infos {\n        bin_names.push(info.name.clone());\n        let binary_path = node_modules_dir.join(&info.path);\n        if is_javascript_binary(&binary_path) {\n            js_bins.insert(info.name.clone());\n        }\n    }\n\n    // 5b. Check for binary conflicts (before moving staging to final location)\n    let mut conflicts: Vec<(String, String)> = Vec::new(); // (bin_name, existing_package)\n\n    for bin_name in &bin_names {\n        if let Some(config) = BinConfig::load(bin_name).await? {\n            // Only conflict if owned by a different package\n            if config.package != package_name {\n                conflicts.push((bin_name.clone(), config.package.clone()));\n            }\n        }\n    }\n\n    if !conflicts.is_empty() {\n        if force {\n            // Auto-uninstall conflicting packages\n            let packages_to_remove: HashSet<_> =\n                conflicts.iter().map(|(_, pkg)| pkg.clone()).collect();\n            for pkg in packages_to_remove {\n                output::raw(&format!(\"Uninstalling {} (conflicts with {})...\", pkg, package_name));\n                // Use Box::pin to avoid recursive async type issues\n                Box::pin(uninstall(&pkg, false)).await?;\n            }\n        } else {\n            // Hard fail with clear error\n            // Clean up staging directory\n            let _ = tokio::fs::remove_dir_all(&staging_dir).await;\n            return Err(Error::BinaryConflict {\n                bin_name: conflicts[0].0.clone(),\n                existing_package: conflicts[0].1.clone(),\n                new_package: package_name.clone(),\n            });\n        }\n    }\n\n    // 6. Move staging to final location\n    let packages_dir = get_packages_dir()?;\n    let final_dir = packages_dir.join(&package_name);\n\n    // Remove existing installation if present\n    if tokio::fs::try_exists(&final_dir).await.unwrap_or(false) {\n        tokio::fs::remove_dir_all(&final_dir).await?;\n    }\n\n    // Create parent directory (handles scoped packages like @scope/pkg)\n    if let Some(parent) = final_dir.parent() {\n        tokio::fs::create_dir_all(parent).await?;\n    }\n    tokio::fs::rename(&staging_dir, &final_dir).await?;\n\n    // 7. Save package metadata\n    let metadata = PackageMetadata::new(\n        package_name.clone(),\n        installed_version.clone(),\n        version.clone(),\n        None, // npm version - could extract from runtime\n        bin_names.clone(),\n        js_bins,\n        \"npm\".to_string(),\n    );\n    metadata.save().await?;\n\n    // 8. Create shims for binaries and save per-binary configs\n    let bin_dir = get_bin_dir()?;\n    for bin_name in &bin_names {\n        create_package_shim(&bin_dir, bin_name, &package_name).await?;\n\n        // Write per-binary config\n        let bin_config = BinConfig::new(\n            bin_name.clone(),\n            package_name.clone(),\n            installed_version.clone(),\n            version.clone(),\n        );\n        bin_config.save().await?;\n    }\n\n    output::raw(&format!(\"Installed {} v{}\", package_name, installed_version));\n    if !bin_names.is_empty() {\n        output::raw(&format!(\"Binaries: {}\", bin_names.join(\", \")));\n    }\n\n    Ok(())\n}\n\n/// Uninstall a global package.\n///\n/// Uses two-phase uninstall:\n/// 1. Try to use PackageMetadata for binary list\n/// 2. Fallback to scanning BinConfig files for orphaned binaries\npub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> {\n    let (package_name, _) = parse_package_spec(package_name);\n\n    // Phase 1: Try to use PackageMetadata for binary list\n    let bins = if let Some(metadata) = PackageMetadata::load(&package_name).await? {\n        metadata.bins.clone()\n    } else {\n        // Phase 2: Fallback - scan BinConfig files for orphaned binaries\n        let orphan_bins = BinConfig::find_by_package(&package_name).await?;\n        if orphan_bins.is_empty() {\n            return Err(Error::ConfigError(\n                format!(\"Package {} is not installed\", package_name).into(),\n            ));\n        }\n        orphan_bins\n    };\n\n    if dry_run {\n        let bin_dir = get_bin_dir()?;\n        let packages_dir = get_packages_dir()?;\n        let package_dir = packages_dir.join(&package_name);\n        let metadata_path = PackageMetadata::metadata_path(&package_name)?;\n\n        output::raw(&format!(\"Would uninstall {}:\", package_name));\n        for bin_name in &bins {\n            output::raw(&format!(\"  - shim: {}\", bin_dir.join(bin_name).as_path().display()));\n        }\n        output::raw(&format!(\"  - package dir: {}\", package_dir.as_path().display()));\n        output::raw(&format!(\"  - metadata: {}\", metadata_path.as_path().display()));\n        return Ok(());\n    }\n\n    // Remove shims and bin configs\n    let bin_dir = get_bin_dir()?;\n    for bin_name in &bins {\n        remove_package_shim(&bin_dir, bin_name).await?;\n        BinConfig::delete(bin_name).await?;\n    }\n\n    // Remove package directory\n    let packages_dir = get_packages_dir()?;\n    let package_dir = packages_dir.join(&package_name);\n    if tokio::fs::try_exists(&package_dir).await.unwrap_or(false) {\n        tokio::fs::remove_dir_all(&package_dir).await?;\n    }\n\n    // Remove metadata file\n    PackageMetadata::delete(&package_name).await?;\n\n    output::raw(&format!(\"Uninstalled {}\", package_name));\n\n    Ok(())\n}\n\n/// Parse package spec into name and optional version.\nfn parse_package_spec(spec: &str) -> (String, Option<String>) {\n    // Handle scoped packages: @scope/name@version\n    if spec.starts_with('@') {\n        // Find the second @ for version\n        if let Some(idx) = spec[1..].find('@') {\n            let idx = idx + 1; // Adjust for the skipped first char\n            return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string()));\n        }\n        return (spec.to_string(), None);\n    }\n\n    // Handle regular packages: name@version\n    if let Some(idx) = spec.find('@') {\n        return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string()));\n    }\n\n    (spec.to_string(), None)\n}\n\n/// Binary info extracted from package.json.\nstruct BinaryInfo {\n    /// Binary name (the command users will run)\n    name: String,\n    /// Relative path to the binary file from package root\n    path: String,\n}\n\n/// Extract binary names and paths from package.json.\nfn extract_binaries(package_json: &serde_json::Value) -> Vec<BinaryInfo> {\n    let mut bins = Vec::new();\n\n    if let Some(bin) = package_json.get(\"bin\") {\n        match bin {\n            serde_json::Value::String(path) => {\n                // Single binary with package name\n                if let Some(name) = package_json[\"name\"].as_str() {\n                    // Get just the package name without scope\n                    let bin_name = name.split('/').last().unwrap_or(name);\n                    bins.push(BinaryInfo { name: bin_name.to_string(), path: path.clone() });\n                }\n            }\n            serde_json::Value::Object(map) => {\n                // Multiple binaries\n                for (name, path) in map {\n                    if let serde_json::Value::String(path) = path {\n                        bins.push(BinaryInfo { name: name.clone(), path: path.clone() });\n                    }\n                }\n            }\n            _ => {}\n        }\n    }\n\n    bins\n}\n\n/// Check if a file is a JavaScript file that should be run with Node.\n///\n/// Returns true if:\n/// - The file has a .js, .mjs, or .cjs extension\n/// - The file has a shebang containing \"node\"\n///\n/// This function safely reads only the first 256 bytes to check the shebang,\n/// avoiding issues with binary files that may not have newlines.\nfn is_javascript_binary(path: &AbsolutePath) -> bool {\n    // Check extension first (fast path, no file I/O)\n    if let Some(ext) = path.as_path().extension() {\n        let ext = ext.to_string_lossy().to_lowercase();\n        if ext == \"js\" || ext == \"mjs\" || ext == \"cjs\" {\n            return true;\n        }\n    }\n\n    // For extensionless files, read only first 256 bytes to check shebang\n    // This is safe even for binary files\n    if let Ok(mut file) = std::fs::File::open(path.as_path()) {\n        let mut buffer = [0u8; 256];\n        if let Ok(n) = file.read(&mut buffer) {\n            if n >= 2 && buffer[0] == b'#' && buffer[1] == b'!' {\n                // Found shebang, check for \"node\" in the first line\n                // Find newline or use entire buffer\n                let end = buffer[..n].iter().position(|&b| b == b'\\n').unwrap_or(n);\n                if let Ok(shebang) = std::str::from_utf8(&buffer[..end]) {\n                    if shebang.contains(\"node\") {\n                        return true;\n                    }\n                }\n            }\n        }\n    }\n\n    false\n}\n\n/// Core shims that should not be overwritten by package binaries.\npub(crate) const CORE_SHIMS: &[&str] = &[\"node\", \"npm\", \"npx\", \"vp\"];\n\n/// Create a shim for a package binary.\n///\n/// On Unix: Creates a symlink to ../current/bin/vp\n/// On Windows: Creates a trampoline .exe that forwards to vp.exe\nasync fn create_package_shim(\n    bin_dir: &vite_path::AbsolutePath,\n    bin_name: &str,\n    package_name: &str,\n) -> Result<(), Error> {\n    // Check for conflicts with core shims\n    if CORE_SHIMS.contains(&bin_name) {\n        output::warn(&format!(\n            \"Package '{}' provides '{}' binary, but it conflicts with a core shim. Skipping.\",\n            package_name, bin_name\n        ));\n        return Ok(());\n    }\n\n    // Ensure bin directory exists\n    tokio::fs::create_dir_all(bin_dir).await?;\n\n    #[cfg(unix)]\n    {\n        let shim_path = bin_dir.join(bin_name);\n\n        // Check if already a managed shim (symlink to ../current/bin/vp)\n        if let Ok(target) = tokio::fs::read_link(&shim_path).await {\n            if target == std::path::Path::new(\"../current/bin/vp\") {\n                return Ok(());\n            }\n            // Exists but points elsewhere (e.g., npm-installed direct symlink) — replace it\n            tokio::fs::remove_file(&shim_path).await?;\n        }\n\n        // Create symlink to ../current/bin/vp\n        tokio::fs::symlink(\"../current/bin/vp\", &shim_path).await?;\n        tracing::debug!(\"Created package shim symlink {:?} -> ../current/bin/vp\", shim_path);\n    }\n\n    #[cfg(windows)]\n    {\n        let shim_path = bin_dir.join(format!(\"{}.exe\", bin_name));\n\n        // Skip if already exists (e.g., re-installing the same package)\n        if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {\n            return Ok(());\n        }\n\n        // Copy the trampoline binary as <bin_name>.exe.\n        // The trampoline detects the tool name from its own filename and sets\n        // VITE_PLUS_SHIM_TOOL env var before spawning vp.exe.\n        let trampoline_src = super::setup::get_trampoline_path()?;\n        tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?;\n\n        // Remove legacy .cmd and shell script wrappers from previous versions.\n        // In Git Bash/MSYS, the extensionless script takes precedence over .exe,\n        // so leftover wrappers would bypass the trampoline.\n        super::setup::cleanup_legacy_windows_shim(bin_dir, bin_name).await;\n\n        tracing::debug!(\"Created package trampoline shim {:?}\", shim_path);\n    }\n\n    Ok(())\n}\n\n/// Remove a shim for a package binary.\nasync fn remove_package_shim(\n    bin_dir: &vite_path::AbsolutePath,\n    bin_name: &str,\n) -> Result<(), Error> {\n    // Don't remove core shims\n    if CORE_SHIMS.contains(&bin_name) {\n        return Ok(());\n    }\n\n    #[cfg(unix)]\n    {\n        let shim_path = bin_dir.join(bin_name);\n        // Use symlink_metadata to detect symlinks (even broken ones)\n        if tokio::fs::symlink_metadata(&shim_path).await.is_ok() {\n            tokio::fs::remove_file(&shim_path).await?;\n        }\n    }\n\n    #[cfg(windows)]\n    {\n        // Remove trampoline .exe shim and legacy .cmd / shell script wrappers.\n        // Best-effort: ignore NotFound errors for files that don't exist.\n        for suffix in &[\".exe\", \".cmd\", \"\"] {\n            let path = if suffix.is_empty() {\n                bin_dir.join(bin_name)\n            } else {\n                bin_dir.join(format!(\"{bin_name}{suffix}\"))\n            };\n            let _ = tokio::fs::remove_file(&path).await;\n        }\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    /// RAII guard that sets `VITE_PLUS_TRAMPOLINE_PATH` to a fake binary on creation\n    /// and clears it on drop. Ensures cleanup even on test panics.\n    #[cfg(windows)]\n    struct FakeTrampolineGuard;\n\n    #[cfg(windows)]\n    impl FakeTrampolineGuard {\n        fn new(dir: &std::path::Path) -> Self {\n            let trampoline = dir.join(\"vp-shim.exe\");\n            std::fs::write(&trampoline, b\"fake-trampoline\").unwrap();\n            unsafe {\n                std::env::set_var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH, &trampoline);\n            }\n            Self\n        }\n    }\n\n    #[cfg(windows)]\n    impl Drop for FakeTrampolineGuard {\n        fn drop(&mut self) {\n            unsafe {\n                std::env::remove_var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH);\n            }\n        }\n    }\n\n    #[tokio::test]\n    #[cfg_attr(windows, serial_test::serial)]\n    async fn test_create_package_shim_creates_bin_dir() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        // Create a temp directory but don't create the bin subdirectory\n        let temp_dir = TempDir::new().unwrap();\n        #[cfg(windows)]\n        let _guard = FakeTrampolineGuard::new(temp_dir.path());\n        let bin_dir = temp_dir.path().join(\"bin\");\n        let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap();\n\n        // Verify bin directory doesn't exist\n        assert!(!bin_dir.as_path().exists());\n\n        // Create a shim - this should create the bin directory\n        create_package_shim(&bin_dir, \"test-shim\", \"test-package\").await.unwrap();\n\n        // Verify bin directory was created\n        assert!(bin_dir.as_path().exists());\n\n        // Verify shim file was created (on Windows, shims have .exe extension)\n        // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata\n        #[cfg(unix)]\n        {\n            let shim_path = bin_dir.join(\"test-shim\");\n            assert!(\n                std::fs::symlink_metadata(shim_path.as_path()).is_ok(),\n                \"Symlink shim should exist\"\n            );\n        }\n        #[cfg(windows)]\n        {\n            let shim_path = bin_dir.join(\"test-shim.exe\");\n            assert!(shim_path.as_path().exists());\n        }\n    }\n\n    #[tokio::test]\n    async fn test_create_package_shim_skips_core_shims() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Try to create a shim for \"node\" which is a core shim\n        create_package_shim(&bin_dir, \"node\", \"some-package\").await.unwrap();\n\n        // Verify the shim was NOT created (core shims should be skipped)\n        #[cfg(unix)]\n        let shim_path = bin_dir.join(\"node\");\n        #[cfg(windows)]\n        let shim_path = bin_dir.join(\"node.exe\");\n        assert!(!shim_path.as_path().exists());\n    }\n\n    #[tokio::test]\n    #[cfg_attr(windows, serial_test::serial)]\n    async fn test_remove_package_shim_removes_shim() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        #[cfg(windows)]\n        let _guard = FakeTrampolineGuard::new(temp_dir.path());\n        let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create a shim\n        create_package_shim(&bin_dir, \"tsc\", \"typescript\").await.unwrap();\n\n        // Verify the shim was created\n        // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata\n        #[cfg(unix)]\n        {\n            let shim_path = bin_dir.join(\"tsc\");\n            assert!(\n                std::fs::symlink_metadata(shim_path.as_path()).is_ok(),\n                \"Shim should exist after creation\"\n            );\n\n            // Remove the shim\n            remove_package_shim(&bin_dir, \"tsc\").await.unwrap();\n\n            // Verify the shim was removed\n            assert!(\n                std::fs::symlink_metadata(shim_path.as_path()).is_err(),\n                \"Shim should be removed\"\n            );\n        }\n        #[cfg(windows)]\n        {\n            let shim_path = bin_dir.join(\"tsc.exe\");\n            assert!(shim_path.as_path().exists(), \"Shim should exist after creation\");\n\n            // Remove the shim\n            remove_package_shim(&bin_dir, \"tsc\").await.unwrap();\n\n            // Verify the shim was removed\n            assert!(!shim_path.as_path().exists(), \"Shim should be removed\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_remove_package_shim_handles_missing_shim() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Remove a shim that doesn't exist - should not error\n        remove_package_shim(&bin_dir, \"nonexistent\").await.unwrap();\n    }\n\n    #[tokio::test]\n    #[cfg_attr(windows, serial_test::serial)]\n    async fn test_uninstall_removes_shims_from_metadata() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = temp_dir.path().to_path_buf();\n        #[cfg(windows)]\n        let _trampoline_guard = FakeTrampolineGuard::new(&temp_path);\n        let _env_guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(&temp_path),\n        );\n\n        // Create bin directory\n        let bin_dir = AbsolutePathBuf::new(temp_path.join(\"bin\")).unwrap();\n        tokio::fs::create_dir_all(&bin_dir).await.unwrap();\n\n        // Create shims for \"tsc\" and \"tsserver\"\n        create_package_shim(&bin_dir, \"tsc\", \"typescript\").await.unwrap();\n        create_package_shim(&bin_dir, \"tsserver\", \"typescript\").await.unwrap();\n\n        // Verify shims exist\n        // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata\n        #[cfg(unix)]\n        {\n            assert!(\n                std::fs::symlink_metadata(bin_dir.join(\"tsc\").as_path()).is_ok(),\n                \"tsc shim should exist\"\n            );\n            assert!(\n                std::fs::symlink_metadata(bin_dir.join(\"tsserver\").as_path()).is_ok(),\n                \"tsserver shim should exist\"\n            );\n        }\n        #[cfg(windows)]\n        {\n            assert!(bin_dir.join(\"tsc.exe\").as_path().exists(), \"tsc.exe shim should exist\");\n            assert!(\n                bin_dir.join(\"tsserver.exe\").as_path().exists(),\n                \"tsserver.exe shim should exist\"\n            );\n        }\n\n        // Create metadata with bins\n        let metadata = PackageMetadata::new(\n            \"typescript\".to_string(),\n            \"5.9.3\".to_string(),\n            \"20.18.0\".to_string(),\n            None,\n            vec![\"tsc\".to_string(), \"tsserver\".to_string()],\n            HashSet::from([\"tsc\".to_string(), \"tsserver\".to_string()]),\n            \"npm\".to_string(),\n        );\n        metadata.save().await.unwrap();\n\n        // Create package directory (needed for uninstall)\n        let packages_dir = AbsolutePathBuf::new(temp_path.join(\"packages\")).unwrap();\n        let package_dir = packages_dir.join(\"typescript\");\n        tokio::fs::create_dir_all(&package_dir).await.unwrap();\n\n        // Verify metadata was saved\n        let loaded = PackageMetadata::load(\"typescript\").await.unwrap();\n        assert!(loaded.is_some(), \"Metadata should be loaded\");\n        let loaded = loaded.unwrap();\n        assert_eq!(loaded.bins, vec![\"tsc\", \"tsserver\"], \"bins should match\");\n\n        // Run uninstall\n        uninstall(\"typescript\", false).await.unwrap();\n\n        // Verify shims were removed\n        #[cfg(unix)]\n        {\n            assert!(!bin_dir.join(\"tsc\").as_path().exists(), \"tsc shim should be removed\");\n            assert!(\n                !bin_dir.join(\"tsserver\").as_path().exists(),\n                \"tsserver shim should be removed\"\n            );\n        }\n        #[cfg(windows)]\n        {\n            assert!(!bin_dir.join(\"tsc.exe\").as_path().exists(), \"tsc.exe shim should be removed\");\n            assert!(\n                !bin_dir.join(\"tsserver.exe\").as_path().exists(),\n                \"tsserver.exe shim should be removed\"\n            );\n        }\n    }\n\n    #[test]\n    fn test_parse_package_spec_simple() {\n        let (name, version) = parse_package_spec(\"typescript\");\n        assert_eq!(name, \"typescript\");\n        assert_eq!(version, None);\n    }\n\n    #[test]\n    fn test_parse_package_spec_with_version() {\n        let (name, version) = parse_package_spec(\"typescript@5.0.0\");\n        assert_eq!(name, \"typescript\");\n        assert_eq!(version, Some(\"5.0.0\".to_string()));\n    }\n\n    #[test]\n    fn test_parse_package_spec_scoped() {\n        let (name, version) = parse_package_spec(\"@types/node\");\n        assert_eq!(name, \"@types/node\");\n        assert_eq!(version, None);\n    }\n\n    #[test]\n    fn test_parse_package_spec_scoped_with_version() {\n        let (name, version) = parse_package_spec(\"@types/node@20.0.0\");\n        assert_eq!(name, \"@types/node\");\n        assert_eq!(version, Some(\"20.0.0\".to_string()));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_with_js_extension() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let js_file = temp_dir.path().join(\"cli.js\");\n        std::fs::write(&js_file, \"console.log('hello')\").unwrap();\n\n        let path = AbsolutePathBuf::new(js_file).unwrap();\n        assert!(is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_with_mjs_extension() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let mjs_file = temp_dir.path().join(\"cli.mjs\");\n        std::fs::write(&mjs_file, \"export default 'hello'\").unwrap();\n\n        let path = AbsolutePathBuf::new(mjs_file).unwrap();\n        assert!(is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_with_cjs_extension() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let cjs_file = temp_dir.path().join(\"cli.cjs\");\n        std::fs::write(&cjs_file, \"module.exports = 'hello'\").unwrap();\n\n        let path = AbsolutePathBuf::new(cjs_file).unwrap();\n        assert!(is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_with_node_shebang() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let cli_file = temp_dir.path().join(\"cli\");\n        std::fs::write(&cli_file, \"#!/usr/bin/env node\\nconsole.log('hello')\").unwrap();\n\n        let path = AbsolutePathBuf::new(cli_file).unwrap();\n        assert!(is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_with_direct_node_shebang() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let cli_file = temp_dir.path().join(\"cli\");\n        std::fs::write(&cli_file, \"#!/usr/bin/node\\nconsole.log('hello')\").unwrap();\n\n        let path = AbsolutePathBuf::new(cli_file).unwrap();\n        assert!(is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_native_executable() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        // Simulate a native binary (ELF header)\n        let native_file = temp_dir.path().join(\"native-cli\");\n        std::fs::write(&native_file, b\"\\x7fELF\").unwrap();\n\n        let path = AbsolutePathBuf::new(native_file).unwrap();\n        assert!(!is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_shell_script() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let shell_file = temp_dir.path().join(\"script.sh\");\n        std::fs::write(&shell_file, \"#!/bin/bash\\necho hello\").unwrap();\n\n        let path = AbsolutePathBuf::new(shell_file).unwrap();\n        assert!(!is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_python_script() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let python_file = temp_dir.path().join(\"script.py\");\n        std::fs::write(&python_file, \"#!/usr/bin/env python3\\nprint('hello')\").unwrap();\n\n        let path = AbsolutePathBuf::new(python_file).unwrap();\n        assert!(!is_javascript_binary(&path));\n    }\n\n    #[test]\n    fn test_is_javascript_binary_empty_file() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let empty_file = temp_dir.path().join(\"empty\");\n        std::fs::write(&empty_file, \"\").unwrap();\n\n        let path = AbsolutePathBuf::new(empty_file).unwrap();\n        assert!(!is_javascript_binary(&path));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/list.rs",
    "content": "//! List command for displaying locally installed Node.js versions.\n//!\n//! Handles `vp env list` to show Node.js versions installed in VITE_PLUS_HOME/js_runtime/node/.\n\nuse std::{cmp::Ordering, process::ExitStatus};\n\nuse owo_colors::OwoColorize;\nuse serde::Serialize;\nuse vite_path::AbsolutePathBuf;\n\nuse super::config;\nuse crate::error::Error;\n\n/// JSON output format for a single installed version\n#[derive(Serialize)]\nstruct InstalledVersionJson {\n    version: String,\n    current: bool,\n    default: bool,\n}\n\n/// Scan the node versions directory and return sorted version strings.\nfn list_installed_versions(node_dir: &std::path::Path) -> Vec<String> {\n    let entries = match std::fs::read_dir(node_dir) {\n        Ok(entries) => entries,\n        Err(_) => return Vec::new(),\n    };\n\n    let mut versions: Vec<String> = entries\n        .filter_map(|entry| {\n            let entry = entry.ok()?;\n            let name = entry.file_name().into_string().ok()?;\n            // Skip hidden directories and non-directories\n            if name.starts_with('.') || !entry.path().is_dir() {\n                return None;\n            }\n            Some(name)\n        })\n        .collect();\n\n    versions.sort_by(|a, b| compare_versions(a, b));\n    versions\n}\n\n/// Compare two version strings numerically (e.g., \"20.18.0\" vs \"22.13.0\").\nfn compare_versions(a: &str, b: &str) -> Ordering {\n    let parse = |v: &str| -> Vec<u64> { v.split('.').filter_map(|p| p.parse().ok()).collect() };\n    let a_parts = parse(a);\n    let b_parts = parse(b);\n    a_parts.cmp(&b_parts)\n}\n\n/// Execute the list command (local installed versions).\npub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result<ExitStatus, Error> {\n    let home_dir =\n        vite_shared::get_vite_plus_home().map_err(|e| Error::ConfigError(format!(\"{e}\").into()))?;\n    let node_dir = home_dir.join(\"js_runtime\").join(\"node\");\n\n    let versions = list_installed_versions(node_dir.as_path());\n\n    if versions.is_empty() {\n        if json_output {\n            println!(\"[]\");\n        } else {\n            println!(\"No Node.js versions installed.\");\n            println!();\n            println!(\"Install a version with: vp env install <version>\");\n        }\n        return Ok(ExitStatus::default());\n    }\n\n    // Resolve current version (gracefully handle errors)\n    let current_version = config::resolve_version(&cwd).await.ok().map(|r| r.version);\n\n    // Load default version\n    let default_version = config::load_config().await.ok().and_then(|c| c.default_node_version);\n\n    if json_output {\n        print_json(&versions, current_version.as_deref(), default_version.as_deref());\n    } else {\n        print_human(&versions, current_version.as_deref(), default_version.as_deref());\n    }\n\n    Ok(ExitStatus::default())\n}\n\n/// Print installed versions as JSON.\nfn print_json(versions: &[String], current: Option<&str>, default: Option<&str>) {\n    let entries: Vec<InstalledVersionJson> = versions\n        .iter()\n        .map(|v| InstalledVersionJson {\n            version: v.clone(),\n            current: current.is_some_and(|c| c == v),\n            default: default.is_some_and(|d| d == v),\n        })\n        .collect();\n\n    // unwrap is safe here since we're serializing simple structs\n    println!(\"{}\", serde_json::to_string_pretty(&entries).unwrap());\n}\n\n/// Print installed versions in human-readable format.\nfn print_human(versions: &[String], current: Option<&str>, default: Option<&str>) {\n    for v in versions {\n        let is_current = current.is_some_and(|c| c == v);\n        let is_default = default.is_some_and(|d| d == v);\n\n        let mut markers = Vec::new();\n        if is_current {\n            markers.push(\"current\");\n        }\n        if is_default {\n            markers.push(\"default\");\n        }\n\n        let marker_str = if markers.is_empty() {\n            String::new()\n        } else {\n            format!(\" {}\", markers.join(\" \").dimmed())\n        };\n\n        let line = format!(\"* v{v}{marker_str}\");\n        if is_current {\n            println!(\"{}\", line.bright_blue());\n        } else {\n            println!(\"{line}\");\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_version_cmp() {\n        assert_eq!(compare_versions(\"18.20.0\", \"20.18.0\"), Ordering::Less);\n        assert_eq!(compare_versions(\"22.13.0\", \"20.18.0\"), Ordering::Greater);\n        assert_eq!(compare_versions(\"20.18.0\", \"20.18.0\"), Ordering::Equal);\n        assert_eq!(compare_versions(\"20.9.0\", \"20.18.0\"), Ordering::Less);\n    }\n\n    #[test]\n    fn test_list_installed_versions_nonexistent_dir() {\n        let versions = list_installed_versions(std::path::Path::new(\"/nonexistent/path\"));\n        assert!(versions.is_empty());\n    }\n\n    #[test]\n    fn test_list_installed_versions_empty_dir() {\n        let dir = tempfile::tempdir().unwrap();\n        let versions = list_installed_versions(dir.path());\n        assert!(versions.is_empty());\n    }\n\n    #[test]\n    fn test_list_installed_versions_with_versions() {\n        let dir = tempfile::tempdir().unwrap();\n        // Create version directories\n        std::fs::create_dir(dir.path().join(\"20.18.0\")).unwrap();\n        std::fs::create_dir(dir.path().join(\"22.13.0\")).unwrap();\n        std::fs::create_dir(dir.path().join(\"18.20.0\")).unwrap();\n        // Create a hidden dir that should be skipped\n        std::fs::create_dir(dir.path().join(\".tmp\")).unwrap();\n        // Create a file that should be skipped\n        std::fs::write(dir.path().join(\"some-file\"), \"\").unwrap();\n\n        let versions = list_installed_versions(dir.path());\n        assert_eq!(versions, vec![\"18.20.0\", \"20.18.0\", \"22.13.0\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/list_remote.rs",
    "content": "//! List-remote command for displaying available Node.js versions from the registry.\n//!\n//! Handles `vp env list-remote` to show available Node.js versions from the Node.js distribution.\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\nuse serde::Serialize;\nuse vite_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry};\n\nuse crate::{cli::SortingMethod, error::Error};\n\n/// Default number of major versions to show\nconst DEFAULT_MAJOR_VERSIONS: usize = 10;\n\n/// JSON output format for version list\n#[derive(Serialize)]\nstruct VersionListJson {\n    versions: Vec<VersionJson>,\n}\n\n/// JSON format for a single version entry\n#[derive(Serialize)]\nstruct VersionJson {\n    version: String,\n    lts: Option<String>,\n    latest: bool,\n    latest_lts: bool,\n}\n\n/// Execute the list-remote command.\npub async fn execute(\n    pattern: Option<String>,\n    lts_only: bool,\n    show_all: bool,\n    json_output: bool,\n    sort: SortingMethod,\n) -> Result<ExitStatus, Error> {\n    let provider = NodeProvider::new();\n    let versions = provider.fetch_version_index().await?;\n\n    if versions.is_empty() {\n        println!(\"No versions found.\");\n        return Ok(ExitStatus::default());\n    }\n\n    // Filter versions based on options\n    let mut filtered = filter_versions(&versions, pattern.as_deref(), lts_only, show_all);\n\n    // fetch_version_index() returns newest-first (desc).\n    // For asc (default), reverse to show oldest-first.\n    if matches!(sort, SortingMethod::Asc) {\n        filtered.reverse();\n    }\n\n    if json_output {\n        print_json(&filtered, &versions)?;\n    } else {\n        print_human(&filtered);\n    }\n\n    Ok(ExitStatus::default())\n}\n\n/// Filter versions based on criteria.\nfn filter_versions<'a>(\n    versions: &'a [NodeVersionEntry],\n    pattern: Option<&str>,\n    lts_only: bool,\n    show_all: bool,\n) -> Vec<&'a NodeVersionEntry> {\n    let mut filtered: Vec<&'a NodeVersionEntry> = versions.iter().collect();\n\n    // Filter by LTS if requested\n    if lts_only {\n        filtered.retain(|v| v.is_lts());\n    }\n\n    // Filter by pattern (major version)\n    if let Some(pattern) = pattern {\n        filtered.retain(|v| {\n            let version_str = v.version.strip_prefix('v').unwrap_or(&v.version);\n            version_str.starts_with(pattern) || version_str.starts_with(&format!(\"{pattern}.\"))\n        });\n    }\n\n    // Limit to recent major versions unless --all is specified\n    if !show_all && pattern.is_none() {\n        filtered = limit_to_recent_majors(filtered, DEFAULT_MAJOR_VERSIONS);\n    }\n\n    filtered\n}\n\n/// Extract major version from a version string like \"v20.18.0\" or \"20.18.0\"\nfn extract_major(version: &str) -> Option<u64> {\n    let version_str = version.strip_prefix('v').unwrap_or(version);\n    version_str.split('.').next()?.parse().ok()\n}\n\n/// Limit versions to the N most recent major versions.\nfn limit_to_recent_majors(\n    versions: Vec<&NodeVersionEntry>,\n    max_majors: usize,\n) -> Vec<&NodeVersionEntry> {\n    // Get unique major versions\n    let mut majors: Vec<u64> = versions.iter().filter_map(|v| extract_major(&v.version)).collect();\n\n    majors.sort_unstable();\n    majors.dedup();\n    majors.reverse();\n\n    // Keep only the most recent N majors\n    let recent_majors: std::collections::HashSet<u64> =\n        majors.into_iter().take(max_majors).collect();\n\n    versions\n        .into_iter()\n        .filter(|v| extract_major(&v.version).is_some_and(|m| recent_majors.contains(&m)))\n        .collect()\n}\n\n/// Print versions as JSON.\nfn print_json(\n    versions: &[&NodeVersionEntry],\n    all_versions: &[NodeVersionEntry],\n) -> Result<(), Error> {\n    // Find the latest version and latest LTS\n    let latest_version = all_versions.first().map(|v| &v.version);\n    let latest_lts_version = all_versions.iter().find(|v| v.is_lts()).map(|v| &v.version);\n\n    let version_list: Vec<VersionJson> = versions\n        .iter()\n        .map(|v| {\n            let lts = match &v.lts {\n                LtsInfo::Codename(name) => Some(name.to_string()),\n                _ => None,\n            };\n            let is_latest = latest_version.is_some_and(|lv| lv == &v.version);\n            let is_latest_lts = latest_lts_version.is_some_and(|llv| llv == &v.version);\n\n            VersionJson {\n                version: v.version.strip_prefix('v').unwrap_or(&v.version).to_string(),\n                lts,\n                latest: is_latest,\n                latest_lts: is_latest_lts,\n            }\n        })\n        .collect();\n\n    let output = VersionListJson { versions: version_list };\n    println!(\"{}\", serde_json::to_string_pretty(&output)?);\n\n    Ok(())\n}\n\n/// Print versions in human-readable format (fnm-style).\nfn print_human(versions: &[&NodeVersionEntry]) {\n    if versions.is_empty() {\n        eprintln!(\"{}\", \"No versions were found!\".red());\n        return;\n    }\n\n    for version in versions {\n        let version_str = &version.version;\n        // Ensure v prefix\n        let display = if version_str.starts_with('v') {\n            version_str.to_string()\n        } else {\n            format!(\"v{version_str}\")\n        };\n\n        if let LtsInfo::Codename(name) = &version.lts {\n            println!(\"{}{}\", display, format!(\" ({name})\").bright_blue());\n        } else {\n            println!(\"{display}\");\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn make_version(version: &str, lts: Option<&str>) -> NodeVersionEntry {\n        NodeVersionEntry {\n            version: version.into(),\n            lts: match lts {\n                Some(name) => LtsInfo::Codename(name.into()),\n                None => LtsInfo::Boolean(false),\n            },\n        }\n    }\n\n    #[test]\n    fn test_filter_versions_lts_only() {\n        let versions = vec![\n            make_version(\"v24.0.0\", None),\n            make_version(\"v22.13.0\", Some(\"Jod\")),\n            make_version(\"v20.18.0\", Some(\"Iron\")),\n        ];\n\n        let filtered = filter_versions(&versions, None, true, false);\n        assert_eq!(filtered.len(), 2);\n        assert!(filtered.iter().all(|v| v.is_lts()));\n    }\n\n    #[test]\n    fn test_filter_versions_by_pattern() {\n        let versions = vec![\n            make_version(\"v24.0.0\", None),\n            make_version(\"v22.13.0\", Some(\"Jod\")),\n            make_version(\"v22.12.0\", Some(\"Jod\")),\n            make_version(\"v20.18.0\", Some(\"Iron\")),\n        ];\n\n        let filtered = filter_versions(&versions, Some(\"22\"), false, true);\n        assert_eq!(filtered.len(), 2);\n        assert!(filtered.iter().all(|v| v.version.starts_with(\"v22.\")));\n    }\n\n    #[test]\n    fn test_limit_to_recent_majors() {\n        let versions = vec![\n            make_version(\"v24.0.0\", None),\n            make_version(\"v23.0.0\", None),\n            make_version(\"v22.13.0\", Some(\"Jod\")),\n            make_version(\"v21.0.0\", None),\n            make_version(\"v20.18.0\", Some(\"Iron\")),\n        ];\n\n        let refs: Vec<&NodeVersionEntry> = versions.iter().collect();\n        let limited = limit_to_recent_majors(refs, 2);\n\n        // Should only have v24 and v23\n        assert_eq!(limited.len(), 2);\n        assert!(limited.iter().any(|v| v.version.starts_with(\"v24.\")));\n        assert!(limited.iter().any(|v| v.version.starts_with(\"v23.\")));\n    }\n\n    #[test]\n    fn test_filter_versions_show_all_returns_all_versions() {\n        // Create versions spanning many major versions (more than DEFAULT_MAJOR_VERSIONS)\n        let versions = vec![\n            make_version(\"v25.0.0\", None),\n            make_version(\"v24.0.0\", None),\n            make_version(\"v23.0.0\", None),\n            make_version(\"v22.13.0\", Some(\"Jod\")),\n            make_version(\"v21.0.0\", None),\n            make_version(\"v20.18.0\", Some(\"Iron\")),\n            make_version(\"v19.0.0\", None),\n            make_version(\"v18.20.0\", Some(\"Hydrogen\")),\n            make_version(\"v17.0.0\", None),\n            make_version(\"v16.20.0\", Some(\"Gallium\")),\n            make_version(\"v15.0.0\", None),\n            make_version(\"v14.0.0\", None),\n        ];\n\n        // Without show_all, should be limited to DEFAULT_MAJOR_VERSIONS (10)\n        let filtered_limited = filter_versions(&versions, None, false, false);\n        assert_eq!(filtered_limited.len(), 10);\n\n        // With show_all=true, should return all versions\n        let filtered_all = filter_versions(&versions, None, false, true);\n        assert_eq!(filtered_all.len(), 12);\n    }\n\n    #[test]\n    fn test_filter_versions_show_all_with_lts_filter() {\n        let versions = vec![\n            make_version(\"v25.0.0\", None),\n            make_version(\"v22.13.0\", Some(\"Jod\")),\n            make_version(\"v20.18.0\", Some(\"Iron\")),\n            make_version(\"v18.20.0\", Some(\"Hydrogen\")),\n        ];\n\n        // With lts_only and show_all, should return all LTS versions\n        let filtered = filter_versions(&versions, None, true, true);\n        assert_eq!(filtered.len(), 3);\n        assert!(filtered.iter().all(|v| v.is_lts()));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/mod.rs",
    "content": "//! Environment management commands.\n//!\n//! This module provides the `vp env` command for managing Node.js environments\n//! through shim-based version management.\n\npub mod bin_config;\npub mod config;\nmod current;\nmod default;\nmod doctor;\nmod exec;\npub mod global_install;\nmod list;\nmod list_remote;\nmod off;\nmod on;\npub mod package_metadata;\npub mod packages;\nmod pin;\nmod setup;\nmod unpin;\nmod r#use;\nmod which;\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::{\n    cli::{EnvArgs, EnvSubcommands},\n    error::Error,\n};\n\nfn print_env_header() {\n    println!(\"{}\", vite_shared::header::vite_plus_header());\n    println!();\n}\n\nfn should_print_env_header(subcommand: &EnvSubcommands) -> bool {\n    match subcommand {\n        EnvSubcommands::Current { json } => !json,\n        EnvSubcommands::List { json } => !json,\n        EnvSubcommands::ListRemote { json, .. } => !json,\n        // Keep these machine-consumable / passthrough commands header-free.\n        EnvSubcommands::Use { .. } | EnvSubcommands::Exec { .. } => false,\n        _ => true,\n    }\n}\n\n/// Execute the env command based on the provided arguments.\npub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result<ExitStatus, Error> {\n    // Handle subcommands first\n    if let Some(subcommand) = args.command {\n        if should_print_env_header(&subcommand) {\n            print_env_header();\n        }\n\n        return match subcommand {\n            crate::cli::EnvSubcommands::Current { json } => current::execute(cwd, json).await,\n            crate::cli::EnvSubcommands::Print => print_env(cwd).await,\n            crate::cli::EnvSubcommands::Default { version } => default::execute(cwd, version).await,\n            crate::cli::EnvSubcommands::On => on::execute().await,\n            crate::cli::EnvSubcommands::Off => off::execute().await,\n            crate::cli::EnvSubcommands::Setup { refresh, env_only } => {\n                setup::execute(refresh, env_only).await\n            }\n            crate::cli::EnvSubcommands::Doctor => doctor::execute(cwd).await,\n            crate::cli::EnvSubcommands::Which { tool } => which::execute(cwd, &tool).await,\n            crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force } => {\n                pin::execute(cwd, version, unpin, no_install, force).await\n            }\n            crate::cli::EnvSubcommands::Unpin => unpin::execute(cwd).await,\n            crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await,\n            crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => {\n                list_remote::execute(pattern, lts, all, json, sort).await\n            }\n            crate::cli::EnvSubcommands::Exec { node, npm, command } => {\n                exec::execute(node.as_deref(), npm.as_deref(), &command).await\n            }\n            crate::cli::EnvSubcommands::Uninstall { version } => {\n                let provider = vite_js_runtime::NodeProvider::new();\n                let resolved = config::resolve_version_alias(&version, &provider).await?;\n                let home_dir = vite_shared::get_vite_plus_home()\n                    .map_err(|e| crate::error::Error::ConfigError(format!(\"{e}\").into()))?;\n                let version_dir = home_dir.join(\"js_runtime\").join(\"node\").join(&resolved);\n                if !version_dir.as_path().exists() {\n                    eprintln!(\"Node.js v{} is not installed\", resolved);\n                    return Ok(exit_status(1));\n                }\n                tokio::fs::remove_dir_all(version_dir.as_path()).await.map_err(|e| {\n                    crate::error::Error::ConfigError(\n                        format!(\"Failed to remove Node.js v{}: {}\", resolved, e).into(),\n                    )\n                })?;\n                println!(\"Uninstalled Node.js v{}\", resolved);\n                Ok(ExitStatus::default())\n            }\n            crate::cli::EnvSubcommands::Use { version, unset, no_install, silent_if_unchanged } => {\n                r#use::execute(cwd, version, unset, no_install, silent_if_unchanged).await\n            }\n            crate::cli::EnvSubcommands::Install { version } => {\n                let (resolved, from_session_override) = if let Some(version) = version {\n                    let provider = vite_js_runtime::NodeProvider::new();\n                    (config::resolve_version_alias(&version, &provider).await?, false)\n                } else {\n                    let resolution = config::resolve_version(&cwd).await?;\n                    let from_session_override = matches!(\n                        resolution.source.as_str(),\n                        config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE\n                    );\n                    match resolution.source.as_str() {\n                        \".node-version\"\n                        | \"engines.node\"\n                        | \"devEngines.runtime\"\n                        | config::VERSION_ENV_VAR\n                        | config::SESSION_VERSION_FILE => {}\n                        _ => {\n                            eprintln!(\"No Node.js version found in current project.\");\n                            eprintln!(\"Specify a version: vp env install <VERSION>\");\n                            eprintln!(\"Or pin one:       vp env pin <VERSION>\");\n                            return Ok(exit_status(1));\n                        }\n                    }\n                    (resolution.version, from_session_override)\n                };\n                println!(\"Installing Node.js v{}...\", resolved);\n                vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &resolved)\n                    .await?;\n                println!(\"Installed Node.js v{}\", resolved);\n                if from_session_override {\n                    eprintln!(\"Note: Installed from session override.\");\n                    eprintln!(\"Run `vp env use --unset` to revert to project version resolution.\");\n                }\n                Ok(ExitStatus::default())\n            }\n        };\n    }\n\n    // No flags provided - show unified help to match `vp env --help`.\n    if !crate::help::print_unified_clap_help_for_path(&[\"env\"]) {\n        // Fallback to clap's built-in help printer if unified rendering fails.\n        use clap::CommandFactory;\n        println!(\"{}\", vite_shared::header::vite_plus_header());\n        println!();\n        crate::cli::Args::command()\n            .find_subcommand(\"env\")\n            .unwrap()\n            .clone()\n            .disable_help_subcommand(true)\n            .print_help()\n            .ok();\n    }\n    Ok(ExitStatus::default())\n}\n\n/// Print shell snippet for setting environment (`vp env print`)\nasync fn print_env(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    // Resolve the Node.js version for the current directory\n    let resolution = config::resolve_version(&cwd).await?;\n\n    // Get the node bin directory\n    let runtime = vite_js_runtime::download_runtime(\n        vite_js_runtime::JsRuntimeType::Node,\n        &resolution.version,\n    )\n    .await?;\n\n    let bin_dir = runtime.get_bin_prefix();\n\n    // Print shell snippet\n    println!(\"# Add to your shell to use this Node.js version for this session:\");\n    println!(\"export PATH=\\\"{}:$PATH\\\"\", bin_dir.as_path().display());\n\n    Ok(ExitStatus::default())\n}\n\n/// Create an exit status with the given code.\nfn exit_status(code: i32) -> ExitStatus {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        ExitStatus::from_raw(code << 8)\n    }\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::ExitStatusExt;\n        ExitStatus::from_raw(code as u32)\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/off.rs",
    "content": "//! Enable system-first mode command.\n//!\n//! Handles `vp env off` to set shim mode to \"system_first\" -\n//! shims prefer system Node.js, fallback to managed if not found.\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\n\nuse super::config::{ShimMode, load_config, save_config};\nuse crate::{error::Error, help};\n\nfn accent_command(command: &str) -> String {\n    if help::should_style_help() {\n        format!(\"`{}`\", command.bright_blue())\n    } else {\n        format!(\"`{command}`\")\n    }\n}\n\n/// Execute the `vp env off` command.\npub async fn execute() -> Result<ExitStatus, Error> {\n    let mut config = load_config().await?;\n\n    if config.shim_mode == ShimMode::SystemFirst {\n        println!(\"Shim mode is already set to system-first.\");\n        println!(\n            \"Shims will prefer system Node.js, falling back to Vite+ managed Node.js if not found.\"\n        );\n        return Ok(ExitStatus::default());\n    }\n\n    config.shim_mode = ShimMode::SystemFirst;\n    save_config(&config).await?;\n\n    println!(\"\\u{2713} Shim mode set to system-first.\");\n    println!();\n    println!(\n        \"Shims will now prefer system Node.js, falling back to Vite+ managed Node.js if not found.\"\n    );\n    println!();\n    println!(\"Run {} to always use the Vite+ managed Node.js.\", accent_command(\"vp env on\"));\n\n    Ok(ExitStatus::default())\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/on.rs",
    "content": "//! Enable managed mode command.\n//!\n//! Handles `vp env on` to set shim mode to \"managed\" - shims always use vite-plus Node.js.\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\n\nuse super::config::{ShimMode, load_config, save_config};\nuse crate::{error::Error, help};\n\nfn accent_command(command: &str) -> String {\n    if help::should_style_help() {\n        format!(\"`{}`\", command.bright_blue())\n    } else {\n        format!(\"`{command}`\")\n    }\n}\n\n/// Execute the `vp env on` command.\npub async fn execute() -> Result<ExitStatus, Error> {\n    let mut config = load_config().await?;\n\n    if config.shim_mode == ShimMode::Managed {\n        println!(\"Shim mode is already set to managed.\");\n        println!(\"Shims will always use the Vite+ managed Node.js.\");\n        return Ok(ExitStatus::default());\n    }\n\n    config.shim_mode = ShimMode::Managed;\n    save_config(&config).await?;\n\n    println!(\"\\u{2713} Shim mode set to managed.\");\n    println!();\n    println!(\"Shims will now always use the Vite+ managed Node.js.\");\n    println!();\n    println!(\"Run {} to prefer system Node.js instead.\", accent_command(\"vp env off\"));\n\n    Ok(ExitStatus::default())\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/package_metadata.rs",
    "content": "//! Package metadata storage for global packages.\n\nuse std::collections::HashSet;\n\nuse chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse vite_path::AbsolutePathBuf;\n\nuse super::config::get_packages_dir;\nuse crate::error::Error;\n\n/// Metadata for a globally installed package.\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct PackageMetadata {\n    /// Package name\n    pub name: String,\n    /// Package version\n    pub version: String,\n    /// Platform versions used during installation\n    pub platform: Platform,\n    /// Binary names provided by this package\n    pub bins: Vec<String>,\n    /// Binary names that are JavaScript files (need Node.js to run).\n    #[serde(default)]\n    pub js_bins: HashSet<String>,\n    /// Package manager used for installation (npm, yarn, pnpm)\n    pub manager: String,\n    /// Installation timestamp\n    pub installed_at: DateTime<Utc>,\n}\n\n/// Platform versions pinned to this package.\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Platform {\n    /// Node.js version\n    pub node: String,\n    /// npm version (if applicable)\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub npm: Option<String>,\n}\n\nimpl PackageMetadata {\n    /// Create new package metadata.\n    pub fn new(\n        name: String,\n        version: String,\n        node_version: String,\n        npm_version: Option<String>,\n        bins: Vec<String>,\n        js_bins: HashSet<String>,\n        manager: String,\n    ) -> Self {\n        Self {\n            name,\n            version,\n            platform: Platform { node: node_version, npm: npm_version },\n            bins,\n            js_bins,\n            manager,\n            installed_at: Utc::now(),\n        }\n    }\n\n    /// Check if a binary requires Node.js to run.\n    pub fn is_js_binary(&self, bin_name: &str) -> bool {\n        self.js_bins.contains(bin_name)\n    }\n\n    /// Get the metadata file path for a package.\n    pub fn metadata_path(package_name: &str) -> Result<AbsolutePathBuf, Error> {\n        let packages_dir = get_packages_dir()?;\n        Ok(packages_dir.join(format!(\"{package_name}.json\")))\n    }\n\n    /// Load metadata for a package.\n    pub async fn load(package_name: &str) -> Result<Option<Self>, Error> {\n        let path = Self::metadata_path(package_name)?;\n        if !tokio::fs::try_exists(&path).await.unwrap_or(false) {\n            return Ok(None);\n        }\n        let content = tokio::fs::read_to_string(&path).await?;\n        let metadata: Self = serde_json::from_str(&content).map_err(|e| {\n            Error::ConfigError(format!(\"Failed to parse package metadata: {e}\").into())\n        })?;\n        Ok(Some(metadata))\n    }\n\n    /// Save metadata for a package.\n    pub async fn save(&self) -> Result<(), Error> {\n        let path = Self::metadata_path(&self.name)?;\n        // Create parent directory (handles scoped packages like @scope/pkg.json)\n        if let Some(parent) = path.parent() {\n            tokio::fs::create_dir_all(parent).await?;\n        }\n\n        let content = serde_json::to_string_pretty(self).map_err(|e| {\n            Error::ConfigError(format!(\"Failed to serialize package metadata: {e}\").into())\n        })?;\n        tokio::fs::write(&path, content).await?;\n        Ok(())\n    }\n\n    /// Delete metadata for a package.\n    pub async fn delete(package_name: &str) -> Result<(), Error> {\n        let path = Self::metadata_path(package_name)?;\n        if tokio::fs::try_exists(&path).await.unwrap_or(false) {\n            tokio::fs::remove_file(&path).await?;\n        }\n        Ok(())\n    }\n\n    /// List all installed packages.\n    pub async fn list_all() -> Result<Vec<Self>, Error> {\n        let packages_dir = get_packages_dir()?;\n        if !tokio::fs::try_exists(&packages_dir).await.unwrap_or(false) {\n            return Ok(Vec::new());\n        }\n\n        let mut packages = Vec::new();\n        list_packages_recursive(&packages_dir, &mut packages).await?;\n        Ok(packages)\n    }\n\n    /// Find the package that provides a given binary.\n    ///\n    /// Returns the package metadata if found, None otherwise.\n    pub async fn find_by_binary(binary_name: &str) -> Result<Option<Self>, Error> {\n        let packages = Self::list_all().await?;\n\n        for package in packages {\n            if package.bins.contains(&binary_name.to_string()) {\n                return Ok(Some(package));\n            }\n        }\n\n        Ok(None)\n    }\n}\n\n/// Recursively list packages in a directory (handles scoped packages in subdirs).\nasync fn list_packages_recursive(\n    dir: &vite_path::AbsolutePath,\n    packages: &mut Vec<PackageMetadata>,\n) -> Result<(), Error> {\n    let mut entries = tokio::fs::read_dir(dir).await?;\n\n    while let Some(entry) = entries.next_entry().await? {\n        let path = entry.path();\n        let file_type = entry.file_type().await?;\n\n        if file_type.is_dir() {\n            // Only recurse into scoped package directories (@scope/)\n            // Skip package installation directories (typescript/, projj/)\n            if let Some(name) = entry.file_name().to_str() {\n                if name.starts_with('@') {\n                    if let Some(abs_path) = AbsolutePathBuf::new(path) {\n                        Box::pin(list_packages_recursive(&abs_path, packages)).await?;\n                    }\n                }\n            }\n        } else if path.extension().is_some_and(|e| e == \"json\") {\n            // Read JSON metadata files\n            if let Ok(content) = tokio::fs::read_to_string(&path).await {\n                if let Ok(metadata) = serde_json::from_str::<PackageMetadata>(&content) {\n                    packages.push(metadata);\n                }\n            }\n        }\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_metadata_path_regular_package() {\n        // Regular package: typescript.json\n        let path = PackageMetadata::metadata_path(\"typescript\").unwrap();\n        assert!(path.as_path().ends_with(\"typescript.json\"));\n    }\n\n    #[test]\n    fn test_metadata_path_scoped_package() {\n        // Scoped package: @types/node.json (inside @types directory)\n        let path = PackageMetadata::metadata_path(\"@types/node\").unwrap();\n        let path_str = path.as_path().to_string_lossy();\n        assert!(\n            path_str.ends_with(\"@types/node.json\"),\n            \"Expected path ending with @types/node.json, got: {}\",\n            path_str\n        );\n    }\n\n    #[tokio::test]\n    async fn test_save_scoped_package_metadata() {\n        use tempfile::TempDir;\n\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = temp_dir.path().to_path_buf();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(&temp_path),\n        );\n\n        let metadata = PackageMetadata::new(\n            \"@scope/test-pkg\".to_string(),\n            \"1.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n            None,\n            vec![\"test-bin\".to_string()],\n            HashSet::from([\"test-bin\".to_string()]),\n            \"npm\".to_string(),\n        );\n\n        // This should not fail with \"No such file or directory\"\n        // because save() should create the @scope parent directory\n        let result = metadata.save().await;\n        assert!(result.is_ok(), \"Failed to save scoped package metadata: {:?}\", result.err());\n\n        // Verify the file exists at the correct location\n        let expected_path = temp_path.join(\"packages\").join(\"@scope\").join(\"test-pkg.json\");\n        assert!(expected_path.exists(), \"Metadata file not found at {:?}\", expected_path);\n    }\n\n    #[tokio::test]\n    async fn test_list_all_includes_scoped_packages() {\n        use tempfile::TempDir;\n\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = temp_dir.path().to_path_buf();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(&temp_path),\n        );\n\n        // Create regular package metadata\n        let regular = PackageMetadata::new(\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n            None,\n            vec![\"tsc\".to_string()],\n            HashSet::from([\"tsc\".to_string()]),\n            \"npm\".to_string(),\n        );\n        regular.save().await.unwrap();\n\n        // Create scoped package metadata\n        let scoped = PackageMetadata::new(\n            \"@types/node\".to_string(),\n            \"20.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n            None,\n            vec![],\n            HashSet::new(),\n            \"npm\".to_string(),\n        );\n        scoped.save().await.unwrap();\n\n        // list_all should find both\n        let all = PackageMetadata::list_all().await.unwrap();\n        assert_eq!(all.len(), 2, \"Expected 2 packages, got {}\", all.len());\n\n        let names: Vec<_> = all.iter().map(|p| p.name.as_str()).collect();\n        assert!(names.contains(&\"typescript\"), \"Missing typescript package\");\n        assert!(names.contains(&\"@types/node\"), \"Missing @types/node package\");\n    }\n\n    #[tokio::test]\n    async fn test_find_by_binary() {\n        use tempfile::TempDir;\n\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = temp_dir.path().to_path_buf();\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(&temp_path),\n        );\n\n        // Create typescript package with tsc and tsserver binaries\n        let typescript = PackageMetadata::new(\n            \"typescript\".to_string(),\n            \"5.0.0\".to_string(),\n            \"20.18.0\".to_string(),\n            None,\n            vec![\"tsc\".to_string(), \"tsserver\".to_string()],\n            HashSet::from([\"tsc\".to_string(), \"tsserver\".to_string()]),\n            \"npm\".to_string(),\n        );\n        typescript.save().await.unwrap();\n\n        // Create eslint package with eslint binary\n        let eslint = PackageMetadata::new(\n            \"eslint\".to_string(),\n            \"9.0.0\".to_string(),\n            \"22.13.0\".to_string(),\n            None,\n            vec![\"eslint\".to_string()],\n            HashSet::from([\"eslint\".to_string()]),\n            \"npm\".to_string(),\n        );\n        eslint.save().await.unwrap();\n\n        // Find by binary should return the correct package\n        let found = PackageMetadata::find_by_binary(\"tsc\").await.unwrap();\n        assert!(found.is_some(), \"Should find package providing tsc\");\n        assert_eq!(found.unwrap().name, \"typescript\");\n\n        let found = PackageMetadata::find_by_binary(\"tsserver\").await.unwrap();\n        assert!(found.is_some(), \"Should find package providing tsserver\");\n        assert_eq!(found.unwrap().name, \"typescript\");\n\n        let found = PackageMetadata::find_by_binary(\"eslint\").await.unwrap();\n        assert!(found.is_some(), \"Should find package providing eslint\");\n        assert_eq!(found.unwrap().name, \"eslint\");\n\n        // Non-existent binary should return None\n        let found = PackageMetadata::find_by_binary(\"nonexistent\").await.unwrap();\n        assert!(found.is_none(), \"Should not find package for nonexistent binary\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/packages.rs",
    "content": "//! List installed global packages.\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\n\nuse super::package_metadata::PackageMetadata;\nuse crate::error::Error;\n\n/// Execute the packages command.\npub async fn execute(json: bool, pattern: Option<&str>) -> Result<ExitStatus, Error> {\n    let all_packages = PackageMetadata::list_all().await?;\n\n    let packages: Vec<_> = if let Some(pat) = pattern {\n        let pat_lower = pat.to_lowercase();\n        all_packages.into_iter().filter(|p| p.name.to_lowercase().contains(&pat_lower)).collect()\n    } else {\n        all_packages\n    };\n\n    if packages.is_empty() {\n        if json {\n            println!(\"[]\");\n        } else if pattern.is_some() {\n            println!(\"No global packages matching '{}'.\", pattern.unwrap());\n            println!();\n            println!(\"Run 'vp list -g' to see all installed global packages.\");\n        } else {\n            println!(\"No global packages installed.\");\n            println!();\n            println!(\"Install packages with: vp install -g <package>\");\n        }\n        return Ok(ExitStatus::default());\n    }\n\n    if json {\n        let json_output = serde_json::to_string_pretty(&packages)\n            .map_err(|e| Error::ConfigError(format!(\"Failed to serialize: {e}\").into()))?;\n        println!(\"{json_output}\");\n    } else {\n        let col_pkg = \"Package\";\n        let col_node = \"Node version\";\n        let col_bins = \"Binaries\";\n\n        let mut w_pkg = col_pkg.len();\n        let mut w_node = col_node.len();\n\n        for pkg in &packages {\n            let name = format!(\"{}@{}\", pkg.name, pkg.version);\n            w_pkg = w_pkg.max(name.len());\n            w_node = w_node.max(pkg.platform.node.len());\n        }\n\n        let gap = 3;\n        println!(\"{:<w_pkg$}{:>gap$}{:<w_node$}{:>gap$}{}\", col_pkg, \"\", col_node, \"\", col_bins);\n        println!(\"{:<w_pkg$}{:>gap$}{:<w_node$}{:>gap$}{}\", \"---\", \"\", \"---\", \"\", \"---\");\n\n        for pkg in &packages {\n            let name = format!(\"{:<w_pkg$}\", format!(\"{}@{}\", pkg.name, pkg.version));\n            let bins = pkg.bins.join(\", \");\n            println!(\n                \"{}{:>gap$}{:<w_node$}{:>gap$}{}\",\n                name.bright_blue(),\n                \"\",\n                pkg.platform.node,\n                \"\",\n                bins\n            );\n        }\n    }\n\n    Ok(ExitStatus::default())\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/pin.rs",
    "content": "//! Pin command for per-directory Node.js version management.\n//!\n//! Handles `vp env pin [VERSION]` to pin a Node.js version in the current directory\n//! by creating or updating a `.node-version` file.\n\nuse std::{io::Write, process::ExitStatus};\n\nuse vite_js_runtime::NodeProvider;\nuse vite_path::AbsolutePathBuf;\nuse vite_shared::output;\n\nuse super::config::{get_config_path, load_config};\nuse crate::error::Error;\n\n/// Node version file name\nconst NODE_VERSION_FILE: &str = \".node-version\";\n\n/// Execute the pin command.\npub async fn execute(\n    cwd: AbsolutePathBuf,\n    version: Option<String>,\n    unpin: bool,\n    no_install: bool,\n    force: bool,\n) -> Result<ExitStatus, Error> {\n    // Handle --unpin flag\n    if unpin {\n        return do_unpin(&cwd).await;\n    }\n\n    match version {\n        Some(v) => do_pin(&cwd, &v, no_install, force).await,\n        None => show_pinned(&cwd).await,\n    }\n}\n\n/// Show the current pinned version.\nasync fn show_pinned(cwd: &AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    let node_version_path = cwd.join(NODE_VERSION_FILE);\n\n    // Check if .node-version exists in current directory\n    if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) {\n        let content = tokio::fs::read_to_string(&node_version_path).await?;\n        let version = content.trim();\n        println!(\"Pinned version: {version}\");\n        println!(\"  Source: {}\", node_version_path.as_path().display());\n        return Ok(ExitStatus::default());\n    }\n\n    // Check for inherited version from parent directories\n    if let Some((version, source_path)) = find_inherited_version(cwd).await? {\n        println!(\"No version pinned in current directory.\");\n        println!(\"  Inherited: {version} from {}\", source_path.as_path().display());\n        return Ok(ExitStatus::default());\n    }\n\n    // No .node-version anywhere - show default\n    let config = load_config().await?;\n    match config.default_node_version {\n        Some(version) => {\n            let config_path = get_config_path()?;\n            println!(\"No version pinned.\");\n            println!(\"  Using default: {version} (from {})\", config_path.as_path().display());\n        }\n        None => {\n            println!(\"No version pinned.\");\n            println!(\"  Run 'vp env pin <version>' to pin a version.\");\n        }\n    }\n\n    Ok(ExitStatus::default())\n}\n\n/// Find .node-version in parent directories.\nasync fn find_inherited_version(\n    cwd: &AbsolutePathBuf,\n) -> Result<Option<(String, AbsolutePathBuf)>, Error> {\n    let mut current: Option<AbsolutePathBuf> = cwd.parent().map(|p| p.to_absolute_path_buf());\n\n    while let Some(dir) = current {\n        let node_version_path = dir.join(NODE_VERSION_FILE);\n        if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) {\n            let content = tokio::fs::read_to_string(&node_version_path).await?;\n            return Ok(Some((content.trim().to_string(), node_version_path)));\n        }\n        current = dir.parent().map(|p| p.to_absolute_path_buf());\n    }\n\n    Ok(None)\n}\n\n/// Pin a version to the current directory.\nasync fn do_pin(\n    cwd: &AbsolutePathBuf,\n    version: &str,\n    no_install: bool,\n    force: bool,\n) -> Result<ExitStatus, Error> {\n    let provider = NodeProvider::new();\n    let node_version_path = cwd.join(NODE_VERSION_FILE);\n\n    // Resolve the version (aliases like lts/latest are resolved to exact versions)\n    let (resolved_version, was_alias) = resolve_version_for_pin(version, &provider).await?;\n\n    // Check if .node-version already exists\n    if !force && tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) {\n        let existing_content = tokio::fs::read_to_string(&node_version_path).await?;\n        let existing_version = existing_content.trim();\n\n        if existing_version == resolved_version {\n            println!(\"Already pinned to {resolved_version}\");\n            return Ok(ExitStatus::default());\n        }\n\n        // Prompt for confirmation\n        print!(\".node-version already exists with version {existing_version}\");\n        println!();\n        print!(\"Overwrite with {resolved_version}? (y/n): \");\n        std::io::stdout().flush()?;\n\n        let mut input = String::new();\n        std::io::stdin().read_line(&mut input)?;\n\n        if !input.trim().eq_ignore_ascii_case(\"y\") {\n            println!(\"Cancelled.\");\n            return Ok(ExitStatus::default());\n        }\n    }\n\n    // Write the version to .node-version\n    tokio::fs::write(&node_version_path, format!(\"{resolved_version}\\n\")).await?;\n\n    // Invalidate resolve cache so the pinned version takes effect immediately\n    crate::shim::invalidate_cache();\n\n    // Print success message\n    if was_alias {\n        output::success(&format!(\n            \"Pinned Node.js version to {resolved_version} (resolved from {version})\"\n        ));\n    } else {\n        output::success(&format!(\"Pinned Node.js version to {resolved_version}\"));\n    }\n    println!(\"  Created {} in {}\", NODE_VERSION_FILE, cwd.as_path().display());\n\n    // Pre-download the version unless --no-install is specified\n    if no_install {\n        output::note(\"Version will be downloaded on first use.\");\n    } else {\n        // Download the runtime\n        match vite_js_runtime::download_runtime(\n            vite_js_runtime::JsRuntimeType::Node,\n            &resolved_version,\n        )\n        .await\n        {\n            Ok(_) => {\n                output::success(&format!(\"Node.js {resolved_version} installed\"));\n            }\n            Err(e) => {\n                output::warn(&format!(\"Failed to download Node.js {resolved_version}: {e}\"));\n                output::note(\"Version will be downloaded on first use.\");\n            }\n        }\n    }\n\n    Ok(ExitStatus::default())\n}\n\n/// Resolve version for pinning.\n///\n/// Aliases (lts, latest) are resolved to exact versions.\n/// Returns (resolved_version, was_alias).\nasync fn resolve_version_for_pin(\n    version: &str,\n    provider: &NodeProvider,\n) -> Result<(String, bool), Error> {\n    match version.to_lowercase().as_str() {\n        \"lts\" => {\n            let resolved = provider.resolve_latest_version().await?;\n            Ok((resolved.to_string(), true))\n        }\n        \"latest\" => {\n            let resolved = provider.resolve_version(\"*\").await?;\n            Ok((resolved.to_string(), true))\n        }\n        _ => {\n            // For exact versions, validate they exist\n            if NodeProvider::is_exact_version(version) {\n                // Validate the version exists by trying to resolve it\n                provider.resolve_version(version).await?;\n                Ok((version.to_string(), false))\n            } else {\n                // For ranges/partial versions, resolve to exact version\n                let resolved = provider.resolve_version(version).await?;\n                Ok((resolved.to_string(), true))\n            }\n        }\n    }\n}\n\n/// Remove the .node-version file from current directory.\npub async fn do_unpin(cwd: &AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    let node_version_path = cwd.join(NODE_VERSION_FILE);\n\n    if !tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) {\n        println!(\"No {} file in current directory.\", NODE_VERSION_FILE);\n        return Ok(ExitStatus::default());\n    }\n\n    tokio::fs::remove_file(&node_version_path).await?;\n\n    // Invalidate resolve cache so the unpinned version falls back correctly\n    crate::shim::invalidate_cache();\n\n    output::success(&format!(\"Removed {} from {}\", NODE_VERSION_FILE, cwd.as_path().display()));\n\n    Ok(ExitStatus::default())\n}\n\n#[cfg(test)]\nmod tests {\n    use serial_test::serial;\n    use tempfile::TempDir;\n    use vite_path::AbsolutePathBuf;\n\n    use super::*;\n\n    #[tokio::test]\n    async fn test_show_pinned_no_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Should not error when no .node-version exists\n        let result = show_pinned(&temp_path).await;\n        assert!(result.is_ok());\n    }\n\n    #[tokio::test]\n    async fn test_show_pinned_with_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let result = show_pinned(&temp_path).await;\n        assert!(result.is_ok());\n    }\n\n    #[tokio::test]\n    async fn test_find_inherited_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version in parent\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // Create subdirectory\n        let subdir = temp_path.join(\"subdir\");\n        tokio::fs::create_dir(&subdir).await.unwrap();\n\n        let result = find_inherited_version(&subdir).await.unwrap();\n        assert!(result.is_some());\n        let (version, _) = result.unwrap();\n        assert_eq!(version, \"20.18.0\");\n    }\n\n    #[tokio::test]\n    async fn test_do_unpin() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version\n        let node_version_path = temp_path.join(\".node-version\");\n        tokio::fs::write(&node_version_path, \"20.18.0\\n\").await.unwrap();\n\n        // Unpin\n        let result = do_unpin(&temp_path).await;\n        assert!(result.is_ok());\n\n        // File should be gone\n        assert!(!tokio::fs::try_exists(&node_version_path).await.unwrap());\n    }\n\n    #[tokio::test]\n    // Run serially: mutates VITE_PLUS_HOME env var which affects invalidate_cache()\n    #[serial]\n    async fn test_do_unpin_invalidates_cache() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Point VITE_PLUS_HOME to temp dir\n        unsafe {\n            std::env::set_var(vite_shared::env_vars::VITE_PLUS_HOME, temp_path.as_path());\n        }\n\n        // Create cache file manually\n        let cache_dir = temp_path.join(\"cache\");\n        std::fs::create_dir_all(&cache_dir).unwrap();\n        let cache_file = cache_dir.join(\"resolve_cache.json\");\n        std::fs::write(&cache_file, r#\"{\"version\":2,\"entries\":{}}\"#).unwrap();\n        assert!(\n            std::fs::metadata(cache_file.as_path()).is_ok(),\n            \"Cache file should exist before unpin\"\n        );\n\n        // Create .node-version and unpin\n        let node_version_path = temp_path.join(\".node-version\");\n        tokio::fs::write(&node_version_path, \"20.18.0\\n\").await.unwrap();\n        let result = do_unpin(&temp_path).await;\n        assert!(result.is_ok());\n\n        // Cache file should be removed by invalidate_cache()\n        assert!(\n            std::fs::metadata(cache_file.as_path()).is_err(),\n            \"Cache file should be removed after unpin\"\n        );\n\n        // Cleanup\n        unsafe {\n            std::env::remove_var(vite_shared::env_vars::VITE_PLUS_HOME);\n        }\n    }\n\n    // Run serially: mutates VITE_PLUS_HOME env var which affects invalidate_cache()\n    #[tokio::test]\n    #[serial]\n    async fn test_do_pin_invalidates_cache() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Point VITE_PLUS_HOME to temp dir\n        unsafe {\n            std::env::set_var(vite_shared::env_vars::VITE_PLUS_HOME, temp_path.as_path());\n        }\n\n        // Create cache file manually\n        let cache_dir = temp_path.join(\"cache\");\n        std::fs::create_dir_all(&cache_dir).unwrap();\n        let cache_file = cache_dir.join(\"resolve_cache.json\");\n        std::fs::write(&cache_file, r#\"{\"version\":2,\"entries\":{}}\"#).unwrap();\n        assert!(\n            std::fs::metadata(cache_file.as_path()).is_ok(),\n            \"Cache file should exist before pin\"\n        );\n\n        // Pin an exact version (no_install=true to skip download, force=true to skip prompt)\n        let result = do_pin(&temp_path, \"20.18.0\", true, true).await;\n        assert!(result.is_ok());\n\n        // .node-version should be created\n        let node_version_path = temp_path.join(\".node-version\");\n        assert!(tokio::fs::try_exists(&node_version_path).await.unwrap());\n        let content = tokio::fs::read_to_string(&node_version_path).await.unwrap();\n        assert_eq!(content.trim(), \"20.18.0\");\n\n        // Cache file should be removed by invalidate_cache()\n        assert!(\n            std::fs::metadata(cache_file.as_path()).is_err(),\n            \"Cache file should be removed after pin\"\n        );\n\n        // Cleanup\n        unsafe {\n            std::env::remove_var(vite_shared::env_vars::VITE_PLUS_HOME);\n        }\n    }\n\n    #[tokio::test]\n    async fn test_do_unpin_no_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Should not error when no file exists\n        let result = do_unpin(&temp_path).await;\n        assert!(result.is_ok());\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_for_pin_partial_version() {\n        let provider = NodeProvider::new();\n\n        // Partial version \"20\" should resolve to an exact version like \"20.x.y\"\n        let (resolved, was_alias) = resolve_version_for_pin(\"20\", &provider).await.unwrap();\n        assert!(was_alias, \"partial version should be treated as alias\");\n\n        // The resolved version should be a full semver version starting with \"20.\"\n        assert!(\n            resolved.starts_with(\"20.\"),\n            \"expected resolved version to start with '20.', got: {resolved}\"\n        );\n\n        // Should be a valid exact version (major.minor.patch)\n        let parts: Vec<&str> = resolved.split('.').collect();\n        assert_eq!(parts.len(), 3, \"expected 3 version parts, got: {resolved}\");\n        assert!(parts.iter().all(|p| p.parse::<u64>().is_ok()), \"all parts should be numeric\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_version_for_pin_exact_version() {\n        let provider = NodeProvider::new();\n\n        // Exact version should be returned as-is\n        let (resolved, was_alias) = resolve_version_for_pin(\"20.18.0\", &provider).await.unwrap();\n        assert!(!was_alias, \"exact version should not be treated as alias\");\n        assert_eq!(resolved, \"20.18.0\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/setup.rs",
    "content": "//! Setup command implementation for creating bin directory and shims.\n//!\n//! Creates the following structure:\n//! - ~/.vite-plus/bin/     - Contains vp symlink and node/npm/npx shims\n//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary\n//!\n//! On Unix:\n//! - bin/vp is a symlink to ../current/bin/vp\n//! - bin/node, bin/npm, bin/npx are symlinks to ../current/bin/vp\n//! - Symlinks preserve argv[0], allowing tool detection via the symlink name\n//!\n//! On Windows:\n//! - bin/vp.exe, bin/node.exe, bin/npm.exe, bin/npx.exe are trampoline executables\n//! - Each trampoline detects its tool name from its own filename and spawns\n//!   current\\bin\\vp.exe with VITE_PLUS_SHIM_TOOL env var set\n//! - This avoids the \"Terminate batch job (Y/N)?\" prompt from .cmd wrappers\n\nuse std::process::ExitStatus;\n\nuse clap::CommandFactory;\nuse owo_colors::OwoColorize;\n\nuse super::config::{get_bin_dir, get_vite_plus_home};\nuse crate::{cli::Args, error::Error, help};\n\n/// Tools to create shims for (node, npm, npx, vpx)\nconst SHIM_TOOLS: &[&str] = &[\"node\", \"npm\", \"npx\", \"vpx\"];\n\nfn accent_command(command: &str) -> String {\n    if help::should_style_help() {\n        format!(\"`{}`\", command.bright_blue())\n    } else {\n        format!(\"`{command}`\")\n    }\n}\n\n/// Execute the setup command.\npub async fn execute(refresh: bool, env_only: bool) -> Result<ExitStatus, Error> {\n    let vite_plus_home = get_vite_plus_home()?;\n\n    // Ensure home directory exists (env files are written here)\n    tokio::fs::create_dir_all(&vite_plus_home).await?;\n\n    // Generate completion scripts\n    generate_completion_scripts(&vite_plus_home).await?;\n\n    // Create env files with PATH guard (prevents duplicate PATH entries)\n    create_env_files(&vite_plus_home).await?;\n\n    if env_only {\n        println!(\"{}\", help::render_heading(\"Setup\"));\n        println!(\"  Updated shell environment files.\");\n        println!(\"  Run {} to verify setup.\", accent_command(\"vp env doctor\"));\n        return Ok(ExitStatus::default());\n    }\n\n    let bin_dir = get_bin_dir()?;\n\n    println!(\"{}\", help::render_heading(\"Setup\"));\n    println!(\"  Preparing vite-plus environment.\");\n    println!();\n\n    // Ensure bin directory exists\n    tokio::fs::create_dir_all(&bin_dir).await?;\n\n    // Get the current executable path (for shims)\n    let current_exe = std::env::current_exe()\n        .map_err(|e| Error::ConfigError(format!(\"Cannot find current executable: {e}\").into()))?;\n\n    // Create wrapper script in bin/\n    setup_vp_wrapper(&bin_dir, refresh).await?;\n\n    // Create shims for node, npm, npx\n    let mut created = Vec::new();\n    let mut skipped = Vec::new();\n\n    for tool in SHIM_TOOLS {\n        let result = create_shim(&current_exe, &bin_dir, tool, refresh).await?;\n        if result {\n            created.push(*tool);\n        } else {\n            skipped.push(*tool);\n        }\n    }\n\n    // Best-effort cleanup of .old files from rename-before-copy on Windows\n    #[cfg(windows)]\n    if refresh {\n        cleanup_old_files(&bin_dir).await;\n    }\n\n    // Print results\n    if !created.is_empty() {\n        println!(\"{}\", help::render_heading(\"Created Shims\"));\n        for tool in &created {\n            let shim_path = bin_dir.join(shim_filename(tool));\n            println!(\"  {}\", shim_path.as_path().display());\n        }\n    }\n\n    if !skipped.is_empty() && !refresh {\n        if !created.is_empty() {\n            println!();\n        }\n        println!(\"{}\", help::render_heading(\"Skipped Shims\"));\n        for tool in &skipped {\n            let shim_path = bin_dir.join(shim_filename(tool));\n            println!(\"  {}\", shim_path.as_path().display());\n        }\n        println!();\n        println!(\"  Use --refresh to update existing shims.\");\n    }\n\n    println!();\n    print_path_instructions(&bin_dir);\n\n    Ok(ExitStatus::default())\n}\n\n/// Create symlink in bin/ that points to current/bin/vp.\nasync fn setup_vp_wrapper(bin_dir: &vite_path::AbsolutePath, refresh: bool) -> Result<(), Error> {\n    #[cfg(unix)]\n    {\n        let bin_vp = bin_dir.join(\"vp\");\n\n        // Create symlink bin/vp -> ../current/bin/vp\n        let should_create_symlink = refresh\n            || !tokio::fs::try_exists(&bin_vp).await.unwrap_or(false)\n            || !is_symlink(&bin_vp).await; // Replace non-symlink with symlink\n\n        if should_create_symlink {\n            // Remove existing if present (could be old wrapper script or file)\n            if tokio::fs::try_exists(&bin_vp).await.unwrap_or(false) {\n                tokio::fs::remove_file(&bin_vp).await?;\n            }\n            // Create relative symlink\n            tokio::fs::symlink(\"../current/bin/vp\", &bin_vp).await?;\n            tracing::debug!(\"Created symlink {:?} -> ../current/bin/vp\", bin_vp);\n        }\n    }\n\n    #[cfg(windows)]\n    {\n        let bin_vp_exe = bin_dir.join(\"vp.exe\");\n\n        // Create trampoline bin/vp.exe that forwards to current\\bin\\vp.exe\n        let should_create = refresh || !tokio::fs::try_exists(&bin_vp_exe).await.unwrap_or(false);\n\n        if should_create {\n            let trampoline_src = get_trampoline_path()?;\n            // On refresh, the existing vp.exe may still be running (the trampoline\n            // that launched us). Windows prevents overwriting a running exe, so we\n            // rename it to a timestamped .old file first, then copy the new one.\n            if tokio::fs::try_exists(&bin_vp_exe).await.unwrap_or(false) {\n                rename_to_old(&bin_vp_exe).await;\n            }\n\n            tokio::fs::copy(trampoline_src.as_path(), &bin_vp_exe).await?;\n            tracing::debug!(\"Created trampoline {:?}\", bin_vp_exe);\n        }\n\n        // Clean up legacy .cmd and shell script wrappers from previous versions\n        if refresh {\n            cleanup_legacy_windows_shim(bin_dir, \"vp\").await;\n        }\n    }\n\n    Ok(())\n}\n\n/// Check if a path is a symlink.\n#[cfg(unix)]\nasync fn is_symlink(path: &vite_path::AbsolutePath) -> bool {\n    match tokio::fs::symlink_metadata(path).await {\n        Ok(m) => m.file_type().is_symlink(),\n        Err(_) => false,\n    }\n}\n\n/// Create a single shim for node/npm/npx.\n///\n/// Returns `true` if the shim was created, `false` if it already exists.\nasync fn create_shim(\n    source: &std::path::Path,\n    bin_dir: &vite_path::AbsolutePath,\n    tool: &str,\n    refresh: bool,\n) -> Result<bool, Error> {\n    let shim_path = bin_dir.join(shim_filename(tool));\n\n    // Check if shim already exists\n    if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {\n        if !refresh {\n            return Ok(false);\n        }\n        // Remove existing shim for refresh.\n        // On Windows, .exe files may be locked (by antivirus, indexer, or\n        // still-running processes), so rename to .old first instead of deleting.\n        #[cfg(windows)]\n        rename_to_old(&shim_path).await;\n        #[cfg(not(windows))]\n        {\n            tokio::fs::remove_file(&shim_path).await?;\n        }\n    }\n\n    #[cfg(unix)]\n    {\n        create_unix_shim(source, &shim_path, tool).await?;\n    }\n\n    #[cfg(windows)]\n    {\n        create_windows_shim(source, bin_dir, tool).await?;\n    }\n\n    Ok(true)\n}\n\n/// Get the filename for a shim (platform-specific).\nfn shim_filename(tool: &str) -> String {\n    #[cfg(windows)]\n    {\n        // All tools use trampoline .exe files on Windows\n        format!(\"{tool}.exe\")\n    }\n\n    #[cfg(not(windows))]\n    {\n        tool.to_string()\n    }\n}\n\n/// Create a Unix shim using symlink to ../current/bin/vp.\n///\n/// Symlinks preserve argv[0], allowing the vp binary to detect which tool\n/// was invoked. This is the same pattern used by Volta.\n#[cfg(unix)]\nasync fn create_unix_shim(\n    _source: &std::path::Path,\n    shim_path: &vite_path::AbsolutePath,\n    _tool: &str,\n) -> Result<(), Error> {\n    // Create symlink to ../current/bin/vp (relative path)\n    tokio::fs::symlink(\"../current/bin/vp\", shim_path).await?;\n    tracing::debug!(\"Created symlink shim at {:?} -> ../current/bin/vp\", shim_path);\n\n    Ok(())\n}\n\n/// Create Windows shims using trampoline `.exe` files.\n///\n/// Each tool gets a copy of the trampoline binary renamed to `<tool>.exe`.\n/// The trampoline detects its tool name from its own filename and spawns\n/// vp.exe with `VITE_PLUS_SHIM_TOOL` set, avoiding the \"Terminate batch job?\"\n/// prompt that `.cmd` wrappers cause on Ctrl+C.\n///\n/// See: <https://github.com/voidzero-dev/vite-plus/issues/835>\n#[cfg(windows)]\nasync fn create_windows_shim(\n    _source: &std::path::Path,\n    bin_dir: &vite_path::AbsolutePath,\n    tool: &str,\n) -> Result<(), Error> {\n    let trampoline_src = get_trampoline_path()?;\n    let shim_path = bin_dir.join(format!(\"{tool}.exe\"));\n    tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?;\n\n    // Clean up legacy .cmd and shell script wrappers from previous versions\n    cleanup_legacy_windows_shim(bin_dir, tool).await;\n\n    tracing::debug!(\"Created trampoline shim {:?}\", shim_path);\n\n    Ok(())\n}\n\n/// Creates completion scripts in `~/.vite-plus/completion/`:\n/// - `vp.bash` (bash)\n/// - `_vp` (zsh, following zsh convention)\n/// - `vp.fish` (fish shell)\n/// - `vp.ps1` (PowerShell)\nasync fn generate_completion_scripts(\n    vite_plus_home: &vite_path::AbsolutePath,\n) -> Result<(), Error> {\n    let mut cmd = Args::command();\n\n    // Create completion directory\n    let completion_dir = vite_plus_home.join(\"completion\");\n    tokio::fs::create_dir_all(&completion_dir).await?;\n\n    // Generate shell completion scripts\n    let completions = [\n        (clap_complete::Shell::Bash, \"vp.bash\"),\n        (clap_complete::Shell::Zsh, \"_vp\"),\n        (clap_complete::Shell::Fish, \"vp.fish\"),\n        (clap_complete::Shell::PowerShell, \"vp.ps1\"),\n    ];\n\n    for (shell, filename) in completions {\n        let path = completion_dir.join(filename);\n        let mut file = std::fs::File::create(&path)?;\n        clap_complete::generate(shell, &mut cmd, \"vp\", &mut file);\n    }\n\n    tracing::debug!(\"Generated completion scripts in {:?}\", completion_dir);\n\n    Ok(())\n}\n\n/// Get the path to the trampoline template binary (vp-shim.exe).\n///\n/// The trampoline binary is distributed alongside vp.exe in the same directory.\n/// In tests, `VITE_PLUS_TRAMPOLINE_PATH` can override the resolved path.\n#[cfg(windows)]\npub(crate) fn get_trampoline_path() -> Result<vite_path::AbsolutePathBuf, Error> {\n    // Allow tests to override the trampoline path\n    if let Ok(override_path) = std::env::var(vite_shared::env_vars::VITE_PLUS_TRAMPOLINE_PATH) {\n        let path = std::path::PathBuf::from(override_path);\n        if path.exists() {\n            return vite_path::AbsolutePathBuf::new(path)\n                .ok_or_else(|| Error::ConfigError(\"Invalid trampoline override path\".into()));\n        }\n    }\n\n    let current_exe = std::env::current_exe()\n        .map_err(|e| Error::ConfigError(format!(\"Cannot find current executable: {e}\").into()))?;\n    let bin_dir = current_exe\n        .parent()\n        .ok_or_else(|| Error::ConfigError(\"Cannot find parent directory of vp.exe\".into()))?;\n    let trampoline = bin_dir.join(\"vp-shim.exe\");\n\n    if !trampoline.exists() {\n        return Err(Error::ConfigError(\n            format!(\n                \"Trampoline binary not found at {}. Re-install vite-plus to fix this.\",\n                trampoline.display()\n            )\n            .into(),\n        ));\n    }\n\n    vite_path::AbsolutePathBuf::new(trampoline)\n        .ok_or_else(|| Error::ConfigError(\"Invalid trampoline path\".into()))\n}\n\n/// Rename an existing `.exe` to a timestamped `.old` file instead of deleting.\n///\n/// On Windows, running `.exe` files can't be deleted or overwritten, but they can\n/// be renamed. The `.old` files are cleaned up by `cleanup_old_files()`.\n#[cfg(windows)]\nasync fn rename_to_old(path: &vite_path::AbsolutePath) {\n    let timestamp = std::time::SystemTime::now()\n        .duration_since(std::time::UNIX_EPOCH)\n        .unwrap_or_default()\n        .as_secs();\n    if let Some(name) = path.as_path().file_name().and_then(|n| n.to_str()) {\n        let old_name = format!(\"{name}.{timestamp}.old\");\n        let old_path = path.as_path().with_file_name(&old_name);\n        if let Err(e) = tokio::fs::rename(path, &old_path).await {\n            tracing::warn!(\"Failed to rename {} to {}: {}\", name, old_name, e);\n        }\n    }\n}\n\n/// Best-effort cleanup of accumulated `.old` files from previous rename-before-copy operations.\n///\n/// When refreshing `bin/vp.exe` on Windows, the running trampoline is renamed to a\n/// timestamped `.old` file. This function tries to delete all such files. Files still\n/// in use by a running process will silently fail to delete and be cleaned up next time.\n#[cfg(windows)]\nasync fn cleanup_old_files(bin_dir: &vite_path::AbsolutePath) {\n    let Ok(mut entries) = tokio::fs::read_dir(bin_dir).await else {\n        return;\n    };\n    while let Ok(Some(entry)) = entries.next_entry().await {\n        let file_name = entry.file_name();\n        let name = file_name.to_string_lossy();\n        if name.ends_with(\".old\") {\n            let _ = tokio::fs::remove_file(entry.path()).await;\n        }\n    }\n}\n\n/// Remove legacy `.cmd` and shell script wrappers from previous versions.\n#[cfg(windows)]\npub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vite_path::AbsolutePath, tool: &str) {\n    // Remove old .cmd wrapper (best-effort, ignore NotFound)\n    let cmd_path = bin_dir.join(format!(\"{tool}.cmd\"));\n    let _ = tokio::fs::remove_file(&cmd_path).await;\n\n    // Remove old shell script wrapper (extensionless, for Git Bash)\n    // Only remove if it starts with #!/bin/sh (not a binary or other file)\n    // Read only the first 9 bytes to avoid loading large files into memory\n    let sh_path = bin_dir.join(tool);\n    let is_shell_script = async {\n        use tokio::io::AsyncReadExt;\n        let mut file = tokio::fs::File::open(&sh_path).await.ok()?;\n        let mut buf = [0u8; 9]; // b\"#!/bin/sh\".len()\n        let n = file.read(&mut buf).await.ok()?;\n        Some(buf[..n].starts_with(b\"#!/bin/sh\"))\n        // file handle dropped here before remove_file\n    }\n    .await;\n    if is_shell_script == Some(true) {\n        let _ = tokio::fs::remove_file(&sh_path).await;\n    }\n}\n\n/// Create env files with PATH guard (prevents duplicate PATH entries).\n///\n/// Creates:\n/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function\n/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function\n/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function\n/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`)\nasync fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> {\n    let bin_path = vite_plus_home.join(\"bin\");\n    let completion_path = vite_plus_home.join(\"completion\");\n\n    // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env)\n    // This makes the env file portable across sessions where HOME may differ\n    let home_dir = vite_shared::EnvConfig::get().user_home;\n    let to_ref = |path: &vite_path::AbsolutePath| -> String {\n        home_dir\n            .as_ref()\n            .and_then(|h| path.as_path().strip_prefix(h).ok())\n            .map(|s| {\n                // Normalize to forward slashes for $HOME/... paths (POSIX-style)\n                format!(\"$HOME/{}\", s.display().to_string().replace('\\\\', \"/\"))\n            })\n            .unwrap_or_else(|| path.as_path().display().to_string())\n    };\n    let bin_path_ref = to_ref(&bin_path);\n\n    // POSIX env file (bash/zsh)\n    // When sourced multiple times, removes existing entry and re-prepends to front\n    // Uses parameter expansion to split PATH around the bin entry in O(1) operations\n    // Includes vp() shell function wrapper for `vp env use` (evals stdout)\n    // Includes shell completion support\n    let env_content = r#\"#!/bin/sh\n# Vite+ environment setup (https://viteplus.dev)\n__vp_bin=\"__VP_BIN__\"\ncase \":${PATH}:\" in\n    *\":${__vp_bin}:\"*)\n        __vp_tmp=\":${PATH}:\"\n        __vp_before=\"${__vp_tmp%%\":${__vp_bin}:\"*}\"\n        __vp_before=\"${__vp_before#:}\"\n        __vp_after=\"${__vp_tmp#*\":${__vp_bin}:\"}\"\n        __vp_after=\"${__vp_after%:}\"\n        export PATH=\"${__vp_bin}${__vp_before:+:${__vp_before}}${__vp_after:+:${__vp_after}}\"\n        unset __vp_tmp __vp_before __vp_after\n        ;;\n    *)\n        export PATH=\"$__vp_bin:$PATH\"\n        ;;\nesac\nunset __vp_bin\n\n# Shell function wrapper: intercepts `vp env use` to eval its stdout,\n# which sets/unsets VITE_PLUS_NODE_VERSION in the current shell session.\nvp() {\n    if [ \"$1\" = \"env\" ] && [ \"$2\" = \"use\" ]; then\n        case \" $* \" in *\" -h \"*|*\" --help \"*) command vp \"$@\"; return; esac\n        __vp_out=\"$(VITE_PLUS_ENV_USE_EVAL_ENABLE=1 command vp \"$@\")\" || return $?\n        eval \"$__vp_out\"\n    else\n        command vp \"$@\"\n    fi\n}\n\n# Shell completion for bash/zsh\n# Source appropriate completion script based on current shell\n# Only load completion in interactive shells with required builtins\nif [ -n \"$BASH_VERSION\" ] && type complete >/dev/null 2>&1; then\n    # Bash shell with completion support\n    __vp_completion=\"__VP_COMPLETION_BASH__\"\n    if [ -f \"$__vp_completion\" ]; then\n        . \"$__vp_completion\"\n    fi\n    unset __vp_completion\nelif [ -n \"$ZSH_VERSION\" ] && type compdef >/dev/null 2>&1; then\n    # Zsh shell with completion support\n    __vp_completion=\"__VP_COMPLETION_ZSH__\"\n    if [ -f \"$__vp_completion\" ]; then\n        . \"$__vp_completion\"\n    fi\n    unset __vp_completion\nfi\n\"#\n    .replace(\"__VP_BIN__\", &bin_path_ref)\n    .replace(\"__VP_COMPLETION_BASH__\", &to_ref(&completion_path.join(\"vp.bash\")))\n    .replace(\"__VP_COMPLETION_ZSH__\", &to_ref(&completion_path.join(\"_vp\")));\n    let env_file = vite_plus_home.join(\"env\");\n    tokio::fs::write(&env_file, env_content).await?;\n\n    // Fish env file with vp wrapper function\n    let env_fish_content = r#\"# Vite+ environment setup (https://viteplus.dev)\nset -l __vp_idx (contains -i -- __VP_BIN__ $PATH)\nand set -e PATH[$__vp_idx]\nset -gx PATH __VP_BIN__ $PATH\n\n# Shell function wrapper: intercepts `vp env use` to eval its stdout,\n# which sets/unsets VITE_PLUS_NODE_VERSION in the current shell session.\nfunction vp\n    if test (count $argv) -ge 2; and test \"$argv[1]\" = \"env\"; and test \"$argv[2]\" = \"use\"\n        if contains -- -h $argv; or contains -- --help $argv\n            command vp $argv; return\n        end\n        set -lx VITE_PLUS_ENV_USE_EVAL_ENABLE 1\n        set -l __vp_out (command vp $argv); or return $status\n        eval $__vp_out\n    else\n        command vp $argv\n    end\nend\n\n# Shell completion for fish\nif not set -q __vp_completion_sourced\n    set -l __vp_completion \"__VP_COMPLETION_FISH__\"\n    if test -f \"$__vp_completion\"\n        source \"$__vp_completion\"\n        set -g __vp_completion_sourced 1\n    end\nend\n\"#\n    .replace(\"__VP_BIN__\", &bin_path_ref)\n    .replace(\"__VP_COMPLETION_FISH__\", &to_ref(&completion_path.join(\"vp.fish\")));\n    let env_fish_file = vite_plus_home.join(\"env.fish\");\n    tokio::fs::write(&env_fish_file, env_fish_content).await?;\n\n    // PowerShell env file\n    let env_ps1_content = r#\"# Vite+ environment setup (https://viteplus.dev)\n$__vp_bin = \"__VP_BIN_WIN__\"\nif ($env:Path -split ';' -notcontains $__vp_bin) {\n    $env:Path = \"$__vp_bin;$env:Path\"\n}\n\n# Shell function wrapper: intercepts `vp env use` to eval its stdout,\n# which sets/unsets VITE_PLUS_NODE_VERSION in the current shell session.\nfunction vp {\n    if ($args.Count -ge 2 -and $args[0] -eq \"env\" -and $args[1] -eq \"use\") {\n        if ($args -contains \"-h\" -or $args -contains \"--help\") {\n            & (Join-Path $__vp_bin \"vp.exe\") @args; return\n        }\n        $env:VITE_PLUS_ENV_USE_EVAL_ENABLE = \"1\"\n        $output = & (Join-Path $__vp_bin \"vp.exe\") @args 2>&1 | ForEach-Object {\n            if ($_ -is [System.Management.Automation.ErrorRecord]) {\n                Write-Host $_.Exception.Message\n            } else {\n                $_\n            }\n        }\n        Remove-Item Env:VITE_PLUS_ENV_USE_EVAL_ENABLE -ErrorAction SilentlyContinue\n        if ($LASTEXITCODE -eq 0 -and $output) {\n            Invoke-Expression ($output -join \"`n\")\n        }\n    } else {\n        & (Join-Path $__vp_bin \"vp.exe\") @args\n    }\n}\n\n# Shell completion for PowerShell\n$__vp_completion = \"__VP_COMPLETION_PS1__\"\nif (Test-Path $__vp_completion) {\n    . $__vp_completion\n}\n\"#;\n\n    // For PowerShell, use the actual absolute path (not $HOME-relative)\n    let bin_path_win = bin_path.as_path().display().to_string();\n    let completion_ps1_win = completion_path.join(\"vp.ps1\").as_path().display().to_string();\n    let env_ps1_content = env_ps1_content\n        .replace(\"__VP_BIN_WIN__\", &bin_path_win)\n        .replace(\"__VP_COMPLETION_PS1__\", &completion_ps1_win);\n    let env_ps1_file = vite_plus_home.join(\"env.ps1\");\n    tokio::fs::write(&env_ps1_file, env_ps1_content).await?;\n\n    // cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions)\n    // Users run `vp-use 24` in cmd.exe instead of `vp env use 24`\n    let vp_use_cmd_content = \"@echo off\\r\\nset VITE_PLUS_ENV_USE_EVAL_ENABLE=1\\r\\nfor /f \\\"delims=\\\" %%i in ('%~dp0..\\\\current\\\\bin\\\\vp.exe env use %*') do %%i\\r\\nset VITE_PLUS_ENV_USE_EVAL_ENABLE=\\r\\n\";\n    // Only write if bin directory exists (it may not during --env-only)\n    if tokio::fs::try_exists(&bin_path).await.unwrap_or(false) {\n        let vp_use_cmd_file = bin_path.join(\"vp-use.cmd\");\n        tokio::fs::write(&vp_use_cmd_file, vp_use_cmd_content).await?;\n    }\n\n    Ok(())\n}\n\n/// Print instructions for adding bin directory to PATH.\nfn print_path_instructions(bin_dir: &vite_path::AbsolutePath) {\n    // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability\n    let home_path = bin_dir\n        .parent()\n        .map(|p| p.as_path().display().to_string())\n        .unwrap_or_else(|| bin_dir.as_path().display().to_string());\n    let home_path = if let Ok(home_dir) = std::env::var(\"HOME\") {\n        if let Some(suffix) = home_path.strip_prefix(&home_dir) {\n            format!(\"$HOME{suffix}\")\n        } else {\n            home_path\n        }\n    } else {\n        home_path\n    };\n\n    println!(\"{}\", help::render_heading(\"Next Steps\"));\n    println!(\"  Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):\");\n    println!();\n    println!(\"  . \\\"{home_path}/env\\\"\");\n    println!();\n    println!(\"  For fish shell, add to ~/.config/fish/config.fish:\");\n    println!();\n    println!(\"  source \\\"{home_path}/env.fish\\\"\");\n    println!();\n    println!(\"  For PowerShell, add to your $PROFILE:\");\n    println!();\n    println!(\"  . \\\"{home_path}/env.ps1\\\"\");\n    println!();\n    println!(\"  For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:\");\n\n    #[cfg(target_os = \"macos\")]\n    {\n        println!(\"  - macOS: Add to ~/.profile or use launchd\");\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        println!(\"  - Linux: Add to ~/.profile for display manager integration\");\n    }\n\n    #[cfg(target_os = \"windows\")]\n    {\n        println!(\"  - Windows: System Properties -> Environment Variables -> Path\");\n    }\n\n    println!();\n    println!(\n        \"  Restart your terminal and IDE, then run {} to verify.\",\n        accent_command(\"vp env doctor\")\n    );\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n    use vite_path::AbsolutePathBuf;\n\n    use super::*;\n\n    /// Helper: create a test_guard with user_home set to the given path.\n    fn home_guard(home: impl Into<std::path::PathBuf>) -> vite_shared::TestEnvGuard {\n        vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            user_home: Some(home.into()),\n            ..vite_shared::EnvConfig::for_test()\n        })\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_creates_all_files() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let env_path = home.join(\"env\");\n        let env_fish_path = home.join(\"env.fish\");\n        let env_ps1_path = home.join(\"env.ps1\");\n        assert!(env_path.as_path().exists(), \"env file should be created\");\n        assert!(env_fish_path.as_path().exists(), \"env.fish file should be created\");\n        assert!(env_ps1_path.as_path().exists(), \"env.ps1 file should be created\");\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_replaces_placeholder_with_home_relative_path() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let env_content = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n        let fish_content = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n\n        // Placeholder should be fully replaced\n        assert!(\n            !env_content.contains(\"__VP_BIN__\"),\n            \"env file should not contain __VP_BIN__ placeholder\"\n        );\n        assert!(\n            !fish_content.contains(\"__VP_BIN__\"),\n            \"env.fish file should not contain __VP_BIN__ placeholder\"\n        );\n\n        // Should use $HOME-relative path since install dir is under HOME\n        assert!(\n            env_content.contains(\"$HOME/bin\"),\n            \"env file should reference $HOME/bin, got: {env_content}\"\n        );\n        assert!(\n            fish_content.contains(\"$HOME/bin\"),\n            \"env.fish file should reference $HOME/bin, got: {fish_content}\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_uses_absolute_path_when_not_under_home() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        // Set user_home to a different path so install dir is NOT under HOME\n        let _guard = home_guard(\"/nonexistent-home-dir\");\n\n        create_env_files(&home).await.unwrap();\n\n        let env_content = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n        let fish_content = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n\n        // Should use absolute path since install dir is not under HOME\n        let expected_bin = home.join(\"bin\");\n        let expected_str = expected_bin.as_path().display().to_string();\n        assert!(\n            env_content.contains(&expected_str),\n            \"env file should use absolute path {expected_str}, got: {env_content}\"\n        );\n        assert!(\n            fish_content.contains(&expected_str),\n            \"env.fish file should use absolute path {expected_str}, got: {fish_content}\"\n        );\n\n        // Should NOT use $HOME-relative path\n        assert!(!env_content.contains(\"$HOME/bin\"), \"env file should not reference $HOME/bin\");\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_posix_contains_path_guard() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let env_content = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n\n        // Verify PATH guard structure: case statement checks for duplicate\n        assert!(\n            env_content.contains(\"case \\\":${PATH}:\\\" in\"),\n            \"env file should contain PATH guard case statement\"\n        );\n        assert!(\n            env_content.contains(\"*\\\":${__vp_bin}:\\\"*)\"),\n            \"env file should check for existing bin in PATH\"\n        );\n        // Verify it re-prepends to front when already present\n        assert!(\n            env_content.contains(\"export PATH=\\\"${__vp_bin}\"),\n            \"env file should re-prepend bin to front of PATH\"\n        );\n        // Verify simple prepend for new entry\n        assert!(\n            env_content.contains(\"export PATH=\\\"$__vp_bin:$PATH\\\"\"),\n            \"env file should prepend bin to PATH for new entry\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_fish_contains_path_guard() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let fish_content = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n\n        // Verify fish PATH guard: remove existing entry before prepending\n        assert!(\n            fish_content.contains(\"contains -i --\"),\n            \"env.fish should check for existing bin in PATH\"\n        );\n        assert!(\n            fish_content.contains(\"set -e PATH[$__vp_idx]\"),\n            \"env.fish should remove existing entry\"\n        );\n        assert!(fish_content.contains(\"set -gx PATH\"), \"env.fish should set PATH globally\");\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_is_idempotent() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        // Create env files twice\n        create_env_files(&home).await.unwrap();\n        let first_env = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n        let first_fish = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n        let first_ps1 = tokio::fs::read_to_string(home.join(\"env.ps1\")).await.unwrap();\n\n        create_env_files(&home).await.unwrap();\n        let second_env = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n        let second_fish = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n        let second_ps1 = tokio::fs::read_to_string(home.join(\"env.ps1\")).await.unwrap();\n\n        assert_eq!(first_env, second_env, \"env file should be identical after second write\");\n        assert_eq!(first_fish, second_fish, \"env.fish file should be identical after second write\");\n        assert_eq!(first_ps1, second_ps1, \"env.ps1 file should be identical after second write\");\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_posix_contains_vp_shell_function() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let env_content = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n\n        // Verify vp() shell function wrapper is present\n        assert!(env_content.contains(\"vp() {\"), \"env file should contain vp() shell function\");\n        assert!(\n            env_content.contains(\"\\\"$1\\\" = \\\"env\\\"\"),\n            \"env file should check for 'env' subcommand\"\n        );\n        assert!(\n            env_content.contains(\"\\\"$2\\\" = \\\"use\\\"\"),\n            \"env file should check for 'use' subcommand\"\n        );\n        assert!(env_content.contains(\"eval \\\"$__vp_out\\\"\"), \"env file should eval the output\");\n        assert!(\n            env_content.contains(\"command vp \\\"$@\\\"\"),\n            \"env file should use 'command vp' for passthrough\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_fish_contains_vp_function() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let fish_content = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n\n        // Verify fish vp function wrapper is present\n        assert!(fish_content.contains(\"function vp\"), \"env.fish file should contain vp function\");\n        assert!(\n            fish_content.contains(\"\\\"$argv[1]\\\" = \\\"env\\\"\"),\n            \"env.fish should check for 'env' subcommand\"\n        );\n        assert!(\n            fish_content.contains(\"\\\"$argv[2]\\\" = \\\"use\\\"\"),\n            \"env.fish should check for 'use' subcommand\"\n        );\n        assert!(\n            fish_content.contains(\"command vp $argv\"),\n            \"env.fish should use 'command vp' for passthrough\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_ps1_contains_vp_function() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let ps1_content = tokio::fs::read_to_string(home.join(\"env.ps1\")).await.unwrap();\n\n        // Verify PowerShell function is present\n        assert!(ps1_content.contains(\"function vp {\"), \"env.ps1 should contain vp function\");\n        assert!(ps1_content.contains(\"Invoke-Expression\"), \"env.ps1 should use Invoke-Expression\");\n        // Should not contain placeholders\n        assert!(\n            !ps1_content.contains(\"__VP_BIN_WIN__\"),\n            \"env.ps1 should not contain __VP_BIN_WIN__ placeholder\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_execute_env_only_creates_home_dir_and_env_files() {\n        let temp_dir = TempDir::new().unwrap();\n        let fresh_home = temp_dir.path().join(\"new-vite-plus\");\n        // Directory does NOT exist yet — execute should create it\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            vite_plus_home: Some(fresh_home.clone()),\n            user_home: Some(temp_dir.path().to_path_buf()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n\n        let status = execute(false, true).await.unwrap();\n        assert!(status.success(), \"execute --env-only should succeed\");\n\n        // Directory should now exist\n        assert!(fresh_home.exists(), \"VITE_PLUS_HOME directory should be created\");\n\n        // Env files should be written\n        assert!(fresh_home.join(\"env\").exists(), \"env file should be created\");\n        assert!(fresh_home.join(\"env.fish\").exists(), \"env.fish file should be created\");\n        assert!(fresh_home.join(\"env.ps1\").exists(), \"env.ps1 file should be created\");\n    }\n\n    #[tokio::test]\n    async fn test_generate_completion_scripts_creates_all_files() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        generate_completion_scripts(&home).await.unwrap();\n\n        let completion_dir = home.join(\"completion\");\n\n        // Verify all completion scripts are created\n        let bash_completion = completion_dir.join(\"vp.bash\");\n        let zsh_completion = completion_dir.join(\"_vp\");\n        let fish_completion = completion_dir.join(\"vp.fish\");\n        let ps1_completion = completion_dir.join(\"vp.ps1\");\n\n        assert!(bash_completion.as_path().exists(), \"bash completion (vp.bash) should be created\");\n        assert!(zsh_completion.as_path().exists(), \"zsh completion (_vp) should be created\");\n        assert!(fish_completion.as_path().exists(), \"fish completion (vp.fish) should be created\");\n        assert!(\n            ps1_completion.as_path().exists(),\n            \"PowerShell completion (vp.ps1) should be created\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_create_env_files_contains_completion() {\n        let temp_dir = TempDir::new().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let _guard = home_guard(temp_dir.path());\n\n        create_env_files(&home).await.unwrap();\n\n        let env_content = tokio::fs::read_to_string(home.join(\"env\")).await.unwrap();\n        let fish_content = tokio::fs::read_to_string(home.join(\"env.fish\")).await.unwrap();\n        let ps1_content = tokio::fs::read_to_string(home.join(\"env.ps1\")).await.unwrap();\n\n        assert!(\n            env_content.contains(\"Shell completion\")\n                && env_content.contains(\"/completion/vp.bash\\\"\"),\n            \"env file should contain bash completion\"\n        );\n        assert!(\n            fish_content.contains(\"Shell completion\")\n                && fish_content.contains(\"/completion/vp.fish\\\"\"),\n            \"env.fish file should contain fish completion\"\n        );\n        assert!(\n            ps1_content.contains(\"Shell completion\")\n                && ps1_content.contains(&format!(\n                    \"{}completion{}vp.ps1\\\"\",\n                    std::path::MAIN_SEPARATOR_STR,\n                    std::path::MAIN_SEPARATOR_STR\n                )),\n            \"env.ps1 file should contain PowerShell completion\"\n        );\n\n        // Verify placeholders are replaced\n        assert!(\n            !env_content.contains(\"__VP_COMPLETION_BASH__\")\n                && !env_content.contains(\"__VP_COMPLETION_ZSH__\"),\n            \"env file should not contain __VP_COMPLETION_* placeholders\"\n        );\n        assert!(\n            !fish_content.contains(\"__VP_COMPLETION_FISH__\"),\n            \"env.fish file should not contain __VP_COMPLETION_FISH__ placeholder\"\n        );\n        assert!(\n            !ps1_content.contains(\"__VP_COMPLETION_PS1__\"),\n            \"env.ps1 file should not contain __VP_COMPLETION_PS1__ placeholder\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/unpin.rs",
    "content": "//! Unpin command - alias for `pin --unpin`.\n//!\n//! Handles `vp env unpin` to remove the `.node-version` file from the current directory.\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::error::Error;\n\n/// Execute the unpin command.\npub async fn execute(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    super::pin::do_unpin(&cwd).await\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/use.rs",
    "content": "//! Implementation of `vp env use` command.\n//!\n//! Outputs shell-appropriate commands to stdout that set (or unset)\n//! the `VITE_PLUS_NODE_VERSION` environment variable. The shell function\n//! wrapper in `~/.vite-plus/env` evals this output to modify the current\n//! shell session.\n//!\n//! All user-facing status messages go to stderr so they don't interfere\n//! with the eval'd output.\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse super::config::{self, VERSION_ENV_VAR};\nuse crate::error::Error;\n\n/// Detected shell type for output formatting.\nenum Shell {\n    /// POSIX shell (bash, zsh, sh)\n    Posix,\n    /// Fish shell\n    Fish,\n    /// PowerShell\n    PowerShell,\n    /// Windows cmd.exe\n    Cmd,\n}\n\n/// Detect the current shell from environment variables.\nfn detect_shell() -> Shell {\n    let config = vite_shared::EnvConfig::get();\n    if config.fish_version.is_some() {\n        Shell::Fish\n    } else if cfg!(windows) && config.ps_module_path.is_some() {\n        Shell::PowerShell\n    } else if cfg!(windows) {\n        Shell::Cmd\n    } else {\n        Shell::Posix\n    }\n}\n\n/// Format a shell export command for the detected shell.\nfn format_export(shell: &Shell, value: &str) -> String {\n    match shell {\n        Shell::Posix => format!(\"export {VERSION_ENV_VAR}={value}\"),\n        Shell::Fish => format!(\"set -gx {VERSION_ENV_VAR} {value}\"),\n        Shell::PowerShell => format!(\"$env:{VERSION_ENV_VAR} = \\\"{value}\\\"\"),\n        Shell::Cmd => format!(\"set {VERSION_ENV_VAR}={value}\"),\n    }\n}\n\n/// Format a shell unset command for the detected shell.\nfn format_unset(shell: &Shell) -> String {\n    match shell {\n        Shell::Posix => format!(\"unset {VERSION_ENV_VAR}\"),\n        Shell::Fish => format!(\"set -e {VERSION_ENV_VAR}\"),\n        Shell::PowerShell => {\n            format!(\"Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue\")\n        }\n        Shell::Cmd => format!(\"set {VERSION_ENV_VAR}=\"),\n    }\n}\n\n/// Whether the shell eval wrapper is active.\n/// When true, the wrapper will eval our stdout to set env vars — no session file needed.\n/// When false (CI, direct invocation), we write a session file so shims can read it.\nfn has_eval_wrapper() -> bool {\n    vite_shared::EnvConfig::get().env_use_eval_enable\n}\n\n/// Execute the `vp env use` command.\npub async fn execute(\n    cwd: AbsolutePathBuf,\n    version: Option<String>,\n    unset: bool,\n    no_install: bool,\n    silent_if_unchanged: bool,\n) -> Result<ExitStatus, Error> {\n    let shell = detect_shell();\n\n    // Handle --unset: remove session override\n    if unset {\n        if has_eval_wrapper() {\n            println!(\"{}\", format_unset(&shell));\n        } else {\n            config::delete_session_version().await?;\n        }\n        eprintln!(\"Reverted to file-based Node.js version resolution\");\n        return Ok(ExitStatus::default());\n    }\n\n    let provider = vite_js_runtime::NodeProvider::new();\n\n    // Resolve version: explicit argument or from project files\n    // When no argument provided, unset session override and resolve from project files\n    let (resolved_version, source_desc) = if let Some(ref ver) = version {\n        let resolved = config::resolve_version_alias(ver, &provider).await?;\n        (resolved, format!(\"{ver}\"))\n    } else {\n        // No version argument - unset session override first\n        if has_eval_wrapper() {\n            println!(\"{}\", format_unset(&shell));\n        } else {\n            config::delete_session_version().await?;\n        }\n        // Now resolve from project files (not from session override)\n        let resolution = config::resolve_version_from_files(&cwd).await?;\n        let source = resolution.source.clone();\n        (resolution.version, source)\n    };\n\n    // Check if already active and suppress output if requested\n    if silent_if_unchanged {\n        let current_env = vite_shared::EnvConfig::get().node_version.map(|v| v.trim().to_string());\n        let current = if !has_eval_wrapper() {\n            current_env.or(config::read_session_version().await)\n        } else {\n            current_env\n        };\n        if current.as_deref() == Some(&resolved_version) {\n            // Already active — idempotent, skip stderr status message\n            if has_eval_wrapper() {\n                println!(\"{}\", format_export(&shell, &resolved_version));\n            } else {\n                config::write_session_version(&resolved_version).await?;\n            }\n            return Ok(ExitStatus::default());\n        }\n    }\n\n    // Ensure version is installed (unless --no-install)\n    if !no_install {\n        let home_dir = vite_shared::get_vite_plus_home()\n            .map_err(|e| Error::ConfigError(format!(\"{e}\").into()))?\n            .join(\"js_runtime\")\n            .join(\"node\")\n            .join(&resolved_version);\n\n        #[cfg(windows)]\n        let binary_path = home_dir.join(\"node.exe\");\n        #[cfg(not(windows))]\n        let binary_path = home_dir.join(\"bin\").join(\"node\");\n\n        if !binary_path.as_path().exists() {\n            eprintln!(\"Installing Node.js v{}...\", resolved_version);\n            vite_js_runtime::download_runtime(\n                vite_js_runtime::JsRuntimeType::Node,\n                &resolved_version,\n            )\n            .await?;\n        }\n    }\n\n    if has_eval_wrapper() {\n        // Output the shell command to stdout (consumed by shell wrapper's eval)\n        println!(\"{}\", format_export(&shell, &resolved_version));\n    } else {\n        // No eval wrapper (CI or direct invocation) — write session file so shims can read it\n        config::write_session_version(&resolved_version).await?;\n    }\n\n    // Status message to stderr (visible to user)\n    eprintln!(\"Using Node.js v{} (resolved from {})\", resolved_version, source_desc);\n\n    Ok(ExitStatus::default())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_detect_shell_posix_even_with_psmodulepath() {\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            ps_module_path: Some(\"/some/path\".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n        let shell = detect_shell();\n        #[cfg(not(windows))]\n        assert!(matches!(shell, Shell::Posix));\n        #[cfg(windows)]\n        assert!(matches!(shell, Shell::PowerShell));\n    }\n\n    #[test]\n    fn test_detect_shell_fish() {\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {\n            fish_version: Some(\"3.7.0\".into()),\n            ..vite_shared::EnvConfig::for_test()\n        });\n        let shell = detect_shell();\n        assert!(matches!(shell, Shell::Fish));\n    }\n\n    #[test]\n    fn test_detect_shell_posix_default() {\n        // All shell detection fields None → defaults\n        let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test());\n        let shell = detect_shell();\n        #[cfg(not(windows))]\n        assert!(matches!(shell, Shell::Posix));\n        #[cfg(windows)]\n        assert!(matches!(shell, Shell::Cmd));\n    }\n\n    #[test]\n    fn test_format_export_posix() {\n        let result = format_export(&Shell::Posix, \"20.18.0\");\n        assert_eq!(result, \"export VITE_PLUS_NODE_VERSION=20.18.0\");\n    }\n\n    #[test]\n    fn test_format_export_fish() {\n        let result = format_export(&Shell::Fish, \"20.18.0\");\n        assert_eq!(result, \"set -gx VITE_PLUS_NODE_VERSION 20.18.0\");\n    }\n\n    #[test]\n    fn test_format_export_powershell() {\n        let result = format_export(&Shell::PowerShell, \"20.18.0\");\n        assert_eq!(result, \"$env:VITE_PLUS_NODE_VERSION = \\\"20.18.0\\\"\");\n    }\n\n    #[test]\n    fn test_format_export_cmd() {\n        let result = format_export(&Shell::Cmd, \"20.18.0\");\n        assert_eq!(result, \"set VITE_PLUS_NODE_VERSION=20.18.0\");\n    }\n\n    #[test]\n    fn test_format_unset_posix() {\n        let result = format_unset(&Shell::Posix);\n        assert_eq!(result, \"unset VITE_PLUS_NODE_VERSION\");\n    }\n\n    #[test]\n    fn test_format_unset_fish() {\n        let result = format_unset(&Shell::Fish);\n        assert_eq!(result, \"set -e VITE_PLUS_NODE_VERSION\");\n    }\n\n    #[test]\n    fn test_format_unset_powershell() {\n        let result = format_unset(&Shell::PowerShell);\n        assert_eq!(result, \"Remove-Item Env:VITE_PLUS_NODE_VERSION -ErrorAction SilentlyContinue\");\n    }\n\n    #[test]\n    fn test_format_unset_cmd() {\n        let result = format_unset(&Shell::Cmd);\n        assert_eq!(result, \"set VITE_PLUS_NODE_VERSION=\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/env/which.rs",
    "content": "//! Which command implementation.\n//!\n//! Shows the path to the tool binary that would be executed.\n//!\n//! For core tools (node, npm, npx), shows the resolved Node.js binary path\n//! along with version and resolution source.\n//! For global packages, shows the binary path plus package metadata.\n\nuse std::process::ExitStatus;\n\nuse chrono::Local;\nuse owo_colors::OwoColorize;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_shared::output;\n\nuse super::{\n    config::{VERSION_ENV_VAR, get_node_modules_dir, get_packages_dir, resolve_version},\n    package_metadata::PackageMetadata,\n};\nuse crate::error::Error;\n\n/// Core tools (node, npm, npx)\nconst CORE_TOOLS: &[&str] = &[\"node\", \"npm\", \"npx\"];\n\n/// Column width for left-side labels in aligned metadata output\nconst LABEL_WIDTH: usize = 10;\n\n/// Execute the which command.\npub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result<ExitStatus, Error> {\n    // Check if this is a core tool\n    if CORE_TOOLS.contains(&tool) {\n        return execute_core_tool(cwd, tool).await;\n    }\n\n    // Check if this is a global package binary\n    if let Some(metadata) = PackageMetadata::find_by_binary(tool).await? {\n        return execute_package_binary(tool, &metadata).await;\n    }\n\n    // Unknown tool\n    output::error(&format!(\"tool '{}' not found\", tool.bold()));\n    eprintln!(\"Not a core tool (node, npm, npx) or installed global package.\");\n    eprintln!(\"Run 'vp list -g' to see installed packages.\");\n    Ok(exit_status(1))\n}\n\n/// Execute which for a core tool (node, npm, npx).\nasync fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result<ExitStatus, Error> {\n    // Resolve version for current directory\n    let resolution = resolve_version(&cwd).await?;\n\n    // Get the tool path\n    let home_dir = vite_shared::get_vite_plus_home()?\n        .join(\"js_runtime\")\n        .join(\"node\")\n        .join(&resolution.version);\n\n    #[cfg(windows)]\n    let tool_path = if tool == \"node\" {\n        home_dir.join(\"node.exe\")\n    } else {\n        home_dir.join(format!(\"{tool}.cmd\"))\n    };\n\n    #[cfg(not(windows))]\n    let tool_path = home_dir.join(\"bin\").join(tool);\n\n    // Check if the tool exists\n    if !tokio::fs::try_exists(&tool_path).await.unwrap_or(false) {\n        output::error(&format!(\"{} not found\", tool.bold()));\n        eprintln!(\"Node.js {} is not installed.\", resolution.version);\n        eprintln!(\"Run 'vp env install {}' to install it.\", resolution.version);\n        return Ok(exit_status(1));\n    }\n\n    // Print binary path (first line, uncolored, pipe-friendly)\n    println!(\"{}\", tool_path.as_path().display());\n\n    // Print metadata\n    let source_display = format_source(&resolution.source, resolution.source_path.as_deref());\n    println!(\"  {:<LABEL_WIDTH$}  {}\", \"Version:\".dimmed(), resolution.version.bright_green());\n    println!(\"  {:<LABEL_WIDTH$}  {}\", \"Source:\".dimmed(), source_display.dimmed());\n\n    Ok(ExitStatus::default())\n}\n\n/// Format the resolution source for human-friendly display.\n///\n/// When a `source_path` is available, shows the full file path instead of just the source type name.\n/// For env var and lts sources, annotations like `(session)` and `(fallback)` are preserved.\nfn format_source(source: &str, source_path: Option<&AbsolutePath>) -> String {\n    match source {\n        s if s == VERSION_ENV_VAR => format!(\"{s} (session)\"),\n        \"lts\" => \"lts (fallback)\".to_string(),\n        _ => match source_path {\n            Some(path) => path.as_path().display().to_string(),\n            None => source.to_string(),\n        },\n    }\n}\n\n/// Execute which for a global package binary.\nasync fn execute_package_binary(\n    tool: &str,\n    metadata: &PackageMetadata,\n) -> Result<ExitStatus, Error> {\n    // Locate the binary path\n    let binary_path = locate_package_binary(&metadata.name, tool)?;\n\n    // Check if binary exists\n    if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) {\n        output::error(&format!(\"binary '{}' not found\", tool.bold()));\n        eprintln!(\"Package {} may need to be reinstalled.\", metadata.name);\n        eprintln!(\"Run 'vp install -g {}' to reinstall.\", metadata.name);\n        return Ok(exit_status(1));\n    }\n\n    // Format installation timestamp (date only)\n    let installed_local = metadata.installed_at.with_timezone(&Local);\n    let installed_str = installed_local.format(\"%Y-%m-%d\").to_string();\n\n    // Print binary path (first line, uncolored, pipe-friendly)\n    println!(\"{}\", binary_path.as_path().display());\n\n    // Print metadata\n    println!(\n        \"  {:<LABEL_WIDTH$}  {}\",\n        \"Package:\".dimmed(),\n        format!(\"{}@{}\", metadata.name, metadata.version).bright_blue()\n    );\n    println!(\"  {:<LABEL_WIDTH$}  {}\", \"Binaries:\".dimmed(), metadata.bins.join(\", \"));\n    println!(\"  {:<LABEL_WIDTH$}  {}\", \"Node:\".dimmed(), metadata.platform.node.bright_green());\n    println!(\"  {:<LABEL_WIDTH$}  {}\", \"Installed:\".dimmed(), installed_str.dimmed());\n\n    Ok(ExitStatus::default())\n}\n\n/// Locate a binary within a package's installation directory.\nfn locate_package_binary(package_name: &str, binary_name: &str) -> Result<AbsolutePathBuf, Error> {\n    let packages_dir = get_packages_dir()?;\n    let package_dir = packages_dir.join(package_name);\n\n    // The binary is referenced in package.json's bin field\n    // npm uses different layouts: Unix=lib/node_modules, Windows=node_modules\n    let node_modules_dir = get_node_modules_dir(&package_dir, package_name);\n    let package_json_path = node_modules_dir.join(\"package.json\");\n\n    if !package_json_path.as_path().exists() {\n        return Err(Error::ConfigError(format!(\"Package {} not found\", package_name).into()));\n    }\n\n    // Read package.json to find the binary path\n    let content = std::fs::read_to_string(package_json_path.as_path())?;\n    let package_json: serde_json::Value = serde_json::from_str(&content)\n        .map_err(|e| Error::ConfigError(format!(\"Failed to parse package.json: {e}\").into()))?;\n\n    let binary_path = match package_json.get(\"bin\") {\n        Some(serde_json::Value::String(path)) => {\n            // Single binary - check if it matches the name\n            let pkg_name = package_json[\"name\"].as_str().unwrap_or(\"\");\n            let expected_name = pkg_name.split('/').last().unwrap_or(pkg_name);\n            if expected_name == binary_name {\n                node_modules_dir.join(path)\n            } else {\n                return Err(Error::ConfigError(\n                    format!(\"Binary {} not found in package\", binary_name).into(),\n                ));\n            }\n        }\n        Some(serde_json::Value::Object(map)) => {\n            // Multiple binaries - find the one we need\n            if let Some(serde_json::Value::String(path)) = map.get(binary_name) {\n                node_modules_dir.join(path)\n            } else {\n                return Err(Error::ConfigError(\n                    format!(\"Binary {} not found in package\", binary_name).into(),\n                ));\n            }\n        }\n        _ => {\n            return Err(Error::ConfigError(\n                format!(\"No bin field in package.json for {}\", package_name).into(),\n            ));\n        }\n    };\n\n    Ok(binary_path)\n}\n\n/// Create an exit status with the given code.\nfn exit_status(code: i32) -> ExitStatus {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        ExitStatus::from_raw(code << 8)\n    }\n    #[cfg(windows)]\n    {\n        use std::os::windows::process::ExitStatusExt;\n        ExitStatus::from_raw(code as u32)\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/implode.rs",
    "content": "//! `vp implode` — completely remove vp and all its data from this system.\n\nuse std::{\n    io::{IsTerminal, Write},\n    process::ExitStatus,\n};\n\nuse directories::BaseDirs;\nuse owo_colors::OwoColorize;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_shared::output;\nuse vite_str::Str;\n\nuse crate::{cli::exit_status, error::Error};\n\n/// All shell profile paths to check, with `is_snippet` flag.\nconst SHELL_PROFILES: &[(&str, bool)] = &[\n    (\".zshenv\", false),\n    (\".zshrc\", false),\n    (\".bash_profile\", false),\n    (\".bashrc\", false),\n    (\".profile\", false),\n    (\".config/fish/conf.d/vite-plus.fish\", true),\n];\n\n/// Abbreviate a path for display: replace `$HOME` prefix with `~`.\nfn abbreviate_home_path(path: &AbsolutePath, user_home: &AbsolutePath) -> Str {\n    match path.strip_prefix(user_home) {\n        Ok(Some(suffix)) => vite_str::format!(\"~/{suffix}\"),\n        _ => Str::from(path.to_string()),\n    }\n}\n\n/// Comment marker written by the install script above the sourcing line.\nconst VITE_PLUS_COMMENT: &str = \"# Vite+ bin\";\n\npub fn execute(yes: bool) -> Result<ExitStatus, Error> {\n    let Ok(home_dir) = vite_shared::get_vite_plus_home() else {\n        output::info(\"vite-plus is not installed (could not determine home directory)\");\n        return Ok(exit_status(0));\n    };\n\n    if !home_dir.as_path().exists() {\n        output::info(\"vite-plus is not installed (directory does not exist)\");\n        return Ok(exit_status(0));\n    }\n\n    // Resolve user home for shell profile paths\n    let base_dirs = BaseDirs::new()\n        .ok_or_else(|| Error::Other(\"Could not determine user home directory\".into()))?;\n    let user_home = AbsolutePathBuf::new(base_dirs.home_dir().to_path_buf()).unwrap();\n\n    // Collect shell profiles that contain Vite+ lines (content cached for cleaning)\n    let affected_profiles = collect_affected_profiles(&user_home);\n\n    // Confirmation\n    if !yes && !confirm_implode(&home_dir, &affected_profiles)? {\n        return Ok(exit_status(0));\n    }\n\n    // Clean shell profiles using cached content (no re-read)\n    clean_affected_profiles(&affected_profiles);\n\n    // Remove Windows PATH entry\n    #[cfg(windows)]\n    {\n        let bin_path = home_dir.join(\"bin\");\n        if let Err(e) = remove_windows_path_entry(&bin_path) {\n            output::warn(&vite_str::format!(\"Failed to clean Windows PATH: {e}\"));\n        } else {\n            output::success(\"Removed vite-plus from Windows PATH\");\n        }\n    }\n\n    // Remove the directory\n    remove_vite_plus_dir(&home_dir)?;\n\n    output::raw(\"\");\n    output::success(\"vite-plus has been removed from your system.\");\n    output::note(\"Restart your terminal to apply shell changes.\");\n\n    Ok(exit_status(0))\n}\n\n/// A shell profile that contains Vite+ sourcing lines.\nstruct AffectedProfile {\n    /// Display name (e.g. \".zshrc\", \".config/fish/conf.d/vite-plus.fish\").\n    name: Str,\n    /// Absolute path to the file.\n    path: AbsolutePathBuf,\n    kind: AffectedProfileKind,\n}\n\n// Indicating whether it's a snippet (remove file) or a main profile (remove lines).\nenum AffectedProfileKind {\n    // A snippet, uninstall would be as easy as removing the file\n    Snippet,\n    Main {\n        /// File content read during detection (reused for cleaning).\n        content: Str,\n    },\n}\n\n/// Collect shell profiles that contain Vite+ sourcing lines.\n/// Content is cached so we don't need to re-read during cleaning.\nfn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec<AffectedProfile> {\n    let mut affected = Vec::new();\n\n    // Build full list of (display_name, path, is_snippet) from the base set\n    let mut profiles: Vec<(Str, AbsolutePathBuf, bool)> = SHELL_PROFILES\n        .iter()\n        .map(|&(name, is_snippet)| {\n            (vite_str::format!(\"~/{name}\"), user_home.join(name), is_snippet)\n        })\n        .collect();\n\n    // If ZDOTDIR is set and differs from $HOME, also check there.\n    if let Ok(zdotdir) = std::env::var(\"ZDOTDIR\")\n        && let Some(zdotdir_path) = AbsolutePathBuf::new(zdotdir.into())\n        && zdotdir_path != *user_home\n    {\n        for name in [\".zshenv\", \".zshrc\"] {\n            let path = zdotdir_path.join(name);\n            let display = abbreviate_home_path(&path, user_home);\n            profiles.push((display, path, false));\n        }\n    }\n\n    // If XDG_CONFIG_HOME is set and differs from $HOME/.config, also check there.\n    if let Ok(xdg_config) = std::env::var(\"XDG_CONFIG_HOME\")\n        && let Some(xdg_path) = AbsolutePathBuf::new(xdg_config.into())\n        && xdg_path != user_home.join(\".config\")\n    {\n        let path = xdg_path.join(\"fish/conf.d/vite-plus.fish\");\n        let display = abbreviate_home_path(&path, user_home);\n        profiles.push((display, path, true));\n    }\n\n    for (name, path, is_snippet) in profiles {\n        // For snippets, check if the file exists only\n        if is_snippet {\n            if let Ok(true) = std::fs::exists(&path) {\n                affected.push(AffectedProfile { name, path, kind: AffectedProfileKind::Snippet })\n            }\n            continue;\n        }\n        // Read directly — if the file doesn't exist, read_to_string returns Err\n        // which .ok().filter() handles gracefully (no redundant exists() check).\n        if let Some(content) =\n            std::fs::read_to_string(&path).ok().filter(|c| has_vite_plus_lines(c))\n        {\n            affected.push(AffectedProfile {\n                name,\n                path,\n                kind: AffectedProfileKind::Main { content: Str::from(content) },\n            });\n        }\n    }\n    affected\n}\n\n/// Show confirmation prompt and require the user to type \"uninstall\".\n/// Returns `Ok(true)` if confirmed, `Ok(false)` if aborted.\nfn confirm_implode(\n    home_dir: &AbsolutePathBuf,\n    affected_profiles: &[AffectedProfile],\n) -> Result<bool, Error> {\n    if !std::io::stdin().is_terminal() {\n        return Err(Error::UserMessage(\n            \"Cannot prompt for confirmation: stdin is not a TTY. Use --yes to skip confirmation.\"\n                .into(),\n        ));\n    }\n\n    output::warn(\"This will completely remove vite-plus from your system!\");\n    output::raw(\"\");\n    output::raw(&vite_str::format!(\"  Directory: {}\", home_dir.as_path().display()));\n    if !affected_profiles.is_empty() {\n        output::raw(\"  Shell profiles to clean:\");\n        for profile in affected_profiles {\n            output::raw(&vite_str::format!(\"    - {}\", profile.name));\n        }\n    }\n    output::raw(\"\");\n    output::raw(&vite_str::format!(\"Type {} to confirm:\", \"uninstall\".bold()));\n\n    // String is needed here for read_line\n    #[expect(clippy::disallowed_types)]\n    let mut input = String::new();\n    std::io::stdout().flush()?;\n    std::io::stdin().read_line(&mut input)?;\n\n    if input.trim() != \"uninstall\" {\n        output::info(\"Aborted.\");\n        return Ok(false);\n    }\n\n    Ok(true)\n}\n\n/// Clean all affected shell profiles using cached content (no re-read).\nfn clean_affected_profiles(affected_profiles: &[AffectedProfile]) {\n    for profile in affected_profiles {\n        match &profile.kind {\n            AffectedProfileKind::Main { content } => {\n                let cleaned = remove_vite_plus_lines(content);\n                match std::fs::write(&profile.path, cleaned.as_bytes()) {\n                    Ok(()) => output::success(&vite_str::format!(\"Cleaned {}\", profile.name)),\n                    Err(e) => {\n                        output::warn(&vite_str::format!(\"Failed to clean {}: {e}\", profile.name));\n                    }\n                }\n            }\n            AffectedProfileKind::Snippet => match std::fs::remove_file(&profile.path) {\n                Ok(()) => output::success(&vite_str::format!(\"Removed {}\", profile.name)),\n                Err(e) => {\n                    output::warn(&vite_str::format!(\"Failed to remove {}: {e}\", profile.name));\n                }\n            },\n        }\n    }\n}\n\n/// Remove the ~/.vite-plus directory.\nfn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> {\n    #[cfg(unix)]\n    {\n        match std::fs::remove_dir_all(home_dir) {\n            Ok(()) => {\n                output::success(&vite_str::format!(\"Removed {}\", home_dir.as_path().display()));\n                Ok(())\n            }\n            Err(e) => {\n                output::error(&vite_str::format!(\n                    \"Failed to remove {}: {e}\",\n                    home_dir.as_path().display()\n                ));\n                Err(Error::CommandExecution(e))\n            }\n        }\n    }\n\n    #[cfg(windows)]\n    {\n        // On Windows, the running `vp` binary is always locked, so direct\n        // removal will fail.  Rename the directory first so the original path\n        // is immediately free for reinstall, then schedule deletion of the\n        // renamed directory via a detached process.\n        let trash_path =\n            home_dir.as_path().with_extension(vite_str::format!(\"removing-{}\", std::process::id()));\n        if let Err(e) = std::fs::rename(home_dir, &trash_path) {\n            output::error(&vite_str::format!(\n                \"Failed to rename {} for removal: {e}\",\n                home_dir.as_path().display()\n            ));\n            return Err(Error::CommandExecution(e));\n        }\n\n        match spawn_deferred_delete(&trash_path) {\n            Ok(_) => {\n                output::success(&vite_str::format!(\n                    \"Scheduled removal of {} (will complete shortly)\",\n                    home_dir.as_path().display()\n                ));\n            }\n            Err(e) => {\n                output::error(&vite_str::format!(\n                    \"Failed to schedule removal of {}: {e}\",\n                    home_dir.as_path().display()\n                ));\n                return Err(Error::CommandExecution(e));\n            }\n        }\n        Ok(())\n    }\n}\n\n/// Build a `cmd.exe` script that retries `rmdir /S /Q` up to 10 times with\n/// 1-second pauses, exiting as soon as the directory is gone.\n#[cfg(windows)]\nfn build_deferred_delete_script(trash_path: &std::path::Path) -> Str {\n    let p = trash_path.to_string_lossy();\n    vite_str::format!(\n        \"for /L %i in (1,1,10) do @(\\\n            if not exist \\\"{p}\\\" exit /B 0 & \\\n            rmdir /S /Q \\\"{p}\\\" 2>NUL & \\\n            if not exist \\\"{p}\\\" exit /B 0 & \\\n            timeout /T 1 /NOBREAK >NUL\\\n        )\"\n    )\n}\n\n/// Spawn a detached `cmd.exe` process that retries deletion of `trash_path`.\n#[cfg(windows)]\nfn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result<std::process::Child> {\n    let script = build_deferred_delete_script(trash_path);\n    std::process::Command::new(\"cmd.exe\")\n        .args([\"/C\", &script])\n        .stdin(std::process::Stdio::null())\n        .stdout(std::process::Stdio::null())\n        .stderr(std::process::Stdio::null())\n        .spawn()\n}\n\n/// Check if file content contains Vite+ sourcing lines.\nfn has_vite_plus_lines(content: &str) -> bool {\n    let pattern = \".vite-plus/env\\\"\";\n    content.lines().any(|line| line.contains(pattern))\n}\n\n/// Remove Vite+ lines from content, returning the cleaned string.\nfn remove_vite_plus_lines(content: &str) -> Str {\n    let pattern = \".vite-plus/env\\\"\";\n    let lines: Vec<&str> = content.lines().collect();\n    let mut remove_indices = Vec::new();\n\n    for (i, line) in lines.iter().enumerate() {\n        if line.contains(pattern) {\n            remove_indices.push(i);\n            // Also remove the comment line above\n            if i > 0 && lines[i - 1].contains(VITE_PLUS_COMMENT) {\n                remove_indices.push(i - 1);\n                // Also remove the blank line before the comment\n                if i > 1 && lines[i - 2].trim().is_empty() {\n                    remove_indices.push(i - 2);\n                }\n            }\n        }\n    }\n\n    if remove_indices.is_empty() {\n        return Str::from(content);\n    }\n\n    #[expect(clippy::disallowed_types)]\n    let mut result = String::with_capacity(content.len());\n    for (i, line) in lines.iter().enumerate() {\n        if !remove_indices.contains(&i) {\n            result.push_str(line);\n            result.push('\\n');\n        }\n    }\n\n    // Preserve trailing newline behavior of original\n    if !content.ends_with('\\n') && result.ends_with('\\n') {\n        result.pop();\n    }\n\n    Str::from(result)\n}\n\n/// Remove `.vite-plus\\bin` from the Windows User PATH via PowerShell.\n#[cfg(windows)]\nfn remove_windows_path_entry(bin_path: &vite_path::AbsolutePath) -> std::io::Result<()> {\n    let bin_str = bin_path.as_path().to_string_lossy();\n    let script = vite_str::format!(\n        \"[Environment]::SetEnvironmentVariable('Path', \\\n         ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | \\\n         Where-Object {{ $_ -ne '{bin_str}' }}) -join ';', 'User')\"\n    );\n    let status = std::process::Command::new(\"powershell\")\n        .args([\"-NoProfile\", \"-Command\", &script])\n        .status()?;\n    if status.success() {\n        Ok(())\n    } else {\n        Err(std::io::Error::new(std::io::ErrorKind::Other, \"PowerShell command failed\"))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    #[cfg(not(windows))]\n    use serial_test::serial;\n\n    use super::*;\n\n    #[test]\n    fn test_remove_vite_plus_lines_posix() {\n        let content = \"# existing config\\nexport FOO=bar\\n\\n# Vite+ bin (https://viteplus.dev)\\n. \\\"$HOME/.vite-plus/env\\\"\\n\";\n        let result = remove_vite_plus_lines(content);\n        assert_eq!(&*result, \"# existing config\\nexport FOO=bar\\n\");\n    }\n\n    #[test]\n    fn test_remove_vite_plus_lines_no_match() {\n        let content = \"# just a normal config\\nexport PATH=/usr/bin\\n\";\n        let result = remove_vite_plus_lines(content);\n        assert_eq!(&*result, content);\n    }\n\n    #[test]\n    fn test_remove_vite_plus_lines_absolute_path() {\n        let content = \"# existing\\n. \\\"/home/user/.vite-plus/env\\\"\\n\";\n        let result = remove_vite_plus_lines(content);\n        assert_eq!(&*result, \"# existing\\n\");\n    }\n\n    #[test]\n    fn test_remove_vite_plus_lines_preserves_surrounding() {\n        let content = \"# before\\nexport A=1\\n\\n# Vite+ bin (https://viteplus.dev)\\n. \\\"$HOME/.vite-plus/env\\\"\\n# after\\nexport B=2\\n\";\n        let result = remove_vite_plus_lines(content);\n        assert_eq!(&*result, \"# before\\nexport A=1\\n# after\\nexport B=2\\n\");\n    }\n\n    #[test]\n    fn test_clean_affected_profiles_integration() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let profile_path = temp_path.join(\".zshrc\");\n        let original = \"# my config\\nexport FOO=bar\\n\\n# Vite+ bin (https://viteplus.dev)\\n. \\\"$HOME/.vite-plus/env\\\"\\n\";\n        std::fs::write(&profile_path, original).unwrap();\n\n        let profiles = vec![AffectedProfile {\n            name: Str::from(\".zshrc\"),\n            path: profile_path.clone(),\n            kind: AffectedProfileKind::Main { content: Str::from(original) },\n        }];\n        clean_affected_profiles(&profiles);\n\n        let result = std::fs::read_to_string(&profile_path).unwrap();\n        assert_eq!(result, \"# my config\\nexport FOO=bar\\n\");\n        assert!(!result.contains(\".vite-plus/env\"));\n    }\n\n    #[test]\n    fn test_remove_vite_plus_dir_success() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let target = dir.join(\"to-remove\");\n        std::fs::create_dir_all(&target).unwrap();\n        std::fs::write(target.join(\"file.txt\"), \"data\").unwrap();\n\n        let result = remove_vite_plus_dir(&target);\n        assert!(result.is_ok());\n        assert!(!target.as_path().exists());\n    }\n\n    #[test]\n    fn test_remove_vite_plus_dir_nonexistent() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let target = dir.join(\"does-not-exist\");\n\n        let result = remove_vite_plus_dir(&target);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    #[cfg(windows)]\n    fn test_build_deferred_delete_script() {\n        let path = std::path::Path::new(r\"C:\\Users\\test\\.vite-plus.removing-1234\");\n        let script = build_deferred_delete_script(path);\n        assert!(script.contains(\"rmdir /S /Q\"));\n        assert!(script.contains(r\"C:\\Users\\test\\.vite-plus.removing-1234\"));\n        assert!(script.contains(\"for /L %i in (1,1,10)\"));\n        assert!(script.contains(\"timeout /T 1 /NOBREAK\"));\n    }\n\n    #[test]\n    #[cfg(not(windows))]\n    fn test_abbreviate_home_path() {\n        let home = AbsolutePathBuf::new(\"/home/user\".into()).unwrap();\n        // Under home → ~/...\n        let under = AbsolutePathBuf::new(\"/home/user/.zshrc\".into()).unwrap();\n        assert_eq!(&*abbreviate_home_path(&under, &home), \"~/.zshrc\");\n        // Outside home → absolute path as-is\n        let outside = AbsolutePathBuf::new(\"/opt/zdotdir/.zshenv\".into()).unwrap();\n        assert_eq!(&*abbreviate_home_path(&outside, &home), \"/opt/zdotdir/.zshenv\");\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_collect_affected_profiles() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Clear ZDOTDIR/XDG_CONFIG_HOME so the test environment doesn't affect results\n        let _guard = ProfileEnvGuard::new(None, None);\n\n        // Main profile with vite-plus line\n        std::fs::write(home.join(\".zshrc\"), \". \\\"$HOME/.vite-plus/env\\\"\\n\").unwrap();\n        // Unrelated profile (should be ignored)\n        std::fs::write(home.join(\".bashrc\"), \"export PATH=/usr/bin\\n\").unwrap();\n        // Snippet file (just needs to exist)\n        let fish_dir = home.join(\".config/fish/conf.d\");\n        std::fs::create_dir_all(&fish_dir).unwrap();\n        std::fs::write(fish_dir.join(\"vite-plus.fish\"), \"source ~/.vite-plus/env.fish\\n\").unwrap();\n\n        let profiles = collect_affected_profiles(&home);\n        assert_eq!(profiles.len(), 2);\n        assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. }));\n        assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet));\n    }\n\n    /// Guard that saves and restores ZDOTDIR and XDG_CONFIG_HOME env vars.\n    #[cfg(not(windows))]\n    struct ProfileEnvGuard {\n        original_zdotdir: Option<std::ffi::OsString>,\n        original_xdg_config: Option<std::ffi::OsString>,\n    }\n\n    #[cfg(not(windows))]\n    impl ProfileEnvGuard {\n        fn new(zdotdir: Option<&std::path::Path>, xdg_config: Option<&std::path::Path>) -> Self {\n            let guard = Self {\n                original_zdotdir: std::env::var_os(\"ZDOTDIR\"),\n                original_xdg_config: std::env::var_os(\"XDG_CONFIG_HOME\"),\n            };\n            unsafe {\n                match zdotdir {\n                    Some(v) => std::env::set_var(\"ZDOTDIR\", v),\n                    None => std::env::remove_var(\"ZDOTDIR\"),\n                }\n                match xdg_config {\n                    Some(v) => std::env::set_var(\"XDG_CONFIG_HOME\", v),\n                    None => std::env::remove_var(\"XDG_CONFIG_HOME\"),\n                }\n            }\n            guard\n        }\n    }\n\n    #[cfg(not(windows))]\n    impl Drop for ProfileEnvGuard {\n        fn drop(&mut self) {\n            unsafe {\n                match &self.original_zdotdir {\n                    Some(v) => std::env::set_var(\"ZDOTDIR\", v),\n                    None => std::env::remove_var(\"ZDOTDIR\"),\n                }\n                match &self.original_xdg_config {\n                    Some(v) => std::env::set_var(\"XDG_CONFIG_HOME\", v),\n                    None => std::env::remove_var(\"XDG_CONFIG_HOME\"),\n                }\n            }\n        }\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_collect_affected_profiles_zdotdir() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().join(\"home\")).unwrap();\n        let zdotdir = temp_dir.path().join(\"zdotdir\");\n        std::fs::create_dir_all(&home).unwrap();\n        std::fs::create_dir_all(&zdotdir).unwrap();\n\n        std::fs::write(zdotdir.join(\".zshenv\"), \". \\\"$HOME/.vite-plus/env\\\"\\n\").unwrap();\n\n        let _guard = ProfileEnvGuard::new(Some(&zdotdir), None);\n\n        let profiles = collect_affected_profiles(&home);\n        let zdotdir_profiles: Vec<_> =\n            profiles.iter().filter(|p| p.path.as_path().starts_with(&zdotdir)).collect();\n        assert_eq!(zdotdir_profiles.len(), 1);\n        assert!(matches!(&zdotdir_profiles[0].kind, AffectedProfileKind::Main { .. }));\n    }\n\n    #[test]\n    #[serial]\n    #[cfg(not(windows))]\n    fn test_collect_affected_profiles_xdg_config() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let home = AbsolutePathBuf::new(temp_dir.path().join(\"home\")).unwrap();\n        let xdg_config = temp_dir.path().join(\"xdg_config\");\n        let fish_dir = xdg_config.join(\"fish/conf.d\");\n        std::fs::create_dir_all(&home).unwrap();\n        std::fs::create_dir_all(&fish_dir).unwrap();\n\n        std::fs::write(fish_dir.join(\"vite-plus.fish\"), \"\").unwrap();\n\n        let _guard = ProfileEnvGuard::new(None, Some(&xdg_config));\n\n        let profiles = collect_affected_profiles(&home);\n        let xdg_profiles: Vec<_> =\n            profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_config)).collect();\n        assert_eq!(xdg_profiles.len(), 1);\n        assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet));\n    }\n\n    #[test]\n    fn test_execute_not_installed() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let non_existent = temp_dir.path().join(\"does-not-exist\");\n        // Use thread-local test guard instead of mutating process-global env\n        let _guard = vite_shared::EnvConfig::test_guard(\n            vite_shared::EnvConfig::for_test_with_home(&non_existent),\n        );\n        let result = execute(true);\n        assert!(result.is_ok());\n        assert!(result.unwrap().success());\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/install.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::{PackageManager, commands::install::InstallCommandOptions};\nuse vite_path::AbsolutePathBuf;\n\nuse super::prepend_js_runtime_to_path_env;\nuse crate::error::Error;\n\n/// Install command.\npub struct InstallCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl InstallCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(self, options: &InstallCommandOptions<'_>) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n        super::ensure_package_json(&self.cwd).await?;\n\n        let package_manager = PackageManager::builder(&self.cwd).build_with_default().await?;\n\n        Ok(package_manager.run_install_command(options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::{fs, path::PathBuf};\n\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[test]\n    fn test_install_command_new() {\n        let workspace_root = AbsolutePathBuf::new(PathBuf::from(if cfg!(windows) {\n            \"C:\\\\test\\\\workspace\"\n        } else {\n            \"/test/workspace\"\n        }))\n        .unwrap();\n        let command = InstallCommand::new(workspace_root.clone());\n\n        assert_eq!(command.cwd, workspace_root);\n    }\n\n    #[ignore = \"skip this test for auto run, should be run manually, because it will prompt for user selection\"]\n    #[tokio::test]\n    async fn test_install_command_with_package_json_without_package_manager() {\n        let temp_dir = TempDir::new().unwrap();\n        let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create a minimal package.json\n        let package_json = r#\"{\n            \"name\": \"test-package\",\n            \"version\": \"1.0.0\"\n        }\"#;\n        fs::write(workspace_root.join(\"package.json\"), package_json).unwrap();\n\n        let command = InstallCommand::new(workspace_root);\n        assert!(command.execute(&InstallCommandOptions::default()).await.is_ok());\n    }\n\n    #[tokio::test]\n    #[serial_test::serial]\n    async fn test_install_command_with_package_json_with_package_manager() {\n        let temp_dir = TempDir::new().unwrap();\n        let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create a minimal package.json\n        let package_json = r#\"{\n            \"name\": \"test-package\",\n            \"version\": \"1.0.0\",\n            \"packageManager\": \"pnpm@10.15.0\"\n        }\"#;\n        fs::write(workspace_root.join(\"package.json\"), package_json).unwrap();\n\n        let command = InstallCommand::new(workspace_root);\n        let result = command.execute(&InstallCommandOptions::default()).await;\n        println!(\"result: {result:?}\");\n        assert!(result.is_ok());\n    }\n\n    #[tokio::test]\n    async fn test_ensure_package_json_creates_when_missing() {\n        let temp_dir = TempDir::new().unwrap();\n        let dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_json_path = dir_path.join(\"package.json\");\n\n        // Verify no package.json exists\n        assert!(!package_json_path.as_path().exists());\n\n        // Call ensure_package_json\n        crate::commands::ensure_package_json(&dir_path).await.unwrap();\n\n        // Verify package.json was created with correct content\n        let content = fs::read_to_string(&package_json_path).unwrap();\n        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();\n        assert_eq!(parsed[\"type\"], \"module\");\n    }\n\n    #[tokio::test]\n    async fn test_ensure_package_json_does_not_overwrite_existing() {\n        let temp_dir = TempDir::new().unwrap();\n        let dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_json_path = dir_path.join(\"package.json\");\n\n        // Create an existing package.json\n        let existing_content = r#\"{\"name\": \"existing-package\"}\"#;\n        fs::write(&package_json_path, existing_content).unwrap();\n\n        // Call ensure_package_json\n        crate::commands::ensure_package_json(&dir_path).await.unwrap();\n\n        // Verify existing package.json was NOT overwritten\n        let content = fs::read_to_string(&package_json_path).unwrap();\n        assert_eq!(content, existing_content);\n    }\n\n    #[tokio::test]\n    async fn test_install_command_execute_with_invalid_workspace() {\n        let temp_dir = TempDir::new().unwrap();\n        let workspace_root = AbsolutePathBuf::new(temp_dir.path().join(\"nonexistent\")).unwrap();\n\n        let command = InstallCommand::new(workspace_root);\n\n        let result = command.execute(&InstallCommandOptions::default()).await;\n        assert!(result.is_err());\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/link.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::link::LinkCommandOptions;\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Link command for local package development.\n///\n/// This command automatically detects the package manager and translates\n/// the link command to the appropriate package manager-specific syntax.\npub struct LinkCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl LinkCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        package: Option<&str>,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let link_command_options = LinkCommandOptions { package, pass_through_args };\n        Ok(package_manager.run_link_command(&link_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_link_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = LinkCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/migrate.rs",
    "content": "//! Migration command (Category B: JavaScript Command).\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::error::Error;\n\n/// Execute the `migrate` command by delegating to local or global vite-plus.\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    super::delegate::execute(cwd, \"migrate\", args).await\n}\n\n#[cfg(test)]\nmod tests {\n    #[test]\n    fn test_migrate_command_module_exists() {\n        // Basic test to ensure the module compiles\n        assert!(true);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/mod.rs",
    "content": "//! Command implementations for the global CLI.\n//!\n//! Commands are organized by category:\n//!\n//! Category A - Package manager commands:\n//! - `add`: Add packages to dependencies\n//! - `install`: Install all dependencies\n//! - `remove`: Remove packages from dependencies\n//! - `update`: Update packages to their latest versions\n//! - `dedupe`: Deduplicate dependencies\n//! - `outdated`: Check for outdated packages\n//! - `why`: Show why a package is installed\n//! - `link`: Link packages for local development\n//! - `unlink`: Unlink packages\n//! - `dlx`: Execute a package binary without installing it\n//! - `pm`: Forward commands to the package manager\n//!\n//! Category B - JS Script Commands:\n//! - `create`: Project scaffolding\n//! - `migrate`: Migration command\n//! - `version`: Version display\n//!\n//! Category C - Local CLI Delegation:\n//! - `delegate`: Local CLI delegation\n\nuse std::{collections::HashMap, io::BufReader};\n\nuse vite_install::package_manager::PackageManager;\nuse vite_path::AbsolutePath;\nuse vite_shared::{PrependOptions, prepend_to_path_env};\n\nuse crate::{error::Error, js_executor::JsExecutor};\n\n#[derive(serde::Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\nstruct DepCheckPackageJson {\n    #[serde(default)]\n    dependencies: HashMap<String, serde_json::Value>,\n    #[serde(default)]\n    dev_dependencies: HashMap<String, serde_json::Value>,\n}\n\n/// Check if vite-plus is listed in the nearest package.json's\n/// dependencies or devDependencies.\n///\n/// Returns `true` if vite-plus is found, `false` if not found\n/// or if no package.json exists.\npub fn has_vite_plus_dependency(cwd: &AbsolutePath) -> bool {\n    let mut current = cwd;\n    loop {\n        let package_json_path = current.join(\"package.json\");\n        if package_json_path.as_path().exists() {\n            if let Ok(file) = std::fs::File::open(&package_json_path) {\n                if let Ok(pkg) =\n                    serde_json::from_reader::<_, DepCheckPackageJson>(BufReader::new(file))\n                {\n                    return pkg.dependencies.contains_key(\"vite-plus\")\n                        || pkg.dev_dependencies.contains_key(\"vite-plus\");\n                }\n            }\n            return false; // Found package.json but couldn't parse deps → treat as no dependency\n        }\n        match current.parent() {\n            Some(parent) if parent != current => current = parent,\n            _ => return false, // Reached filesystem root\n        }\n    }\n}\n\n/// Ensure a package.json exists in the given directory.\n/// If it doesn't exist, create a minimal one with `{ \"type\": \"module\" }`.\npub async fn ensure_package_json(project_path: &AbsolutePath) -> Result<(), Error> {\n    let package_json_path = project_path.join(\"package.json\");\n    if !package_json_path.as_path().exists() {\n        let content = serde_json::to_string_pretty(&serde_json::json!({\n            \"type\": \"module\"\n        }))?;\n        tokio::fs::write(&package_json_path, format!(\"{content}\\n\")).await?;\n        tracing::info!(\"Created package.json in {:?}\", project_path);\n    }\n    Ok(())\n}\n\n/// Ensure the JS runtime is downloaded and prepend its bin directory to PATH.\n/// This should be called before executing any package manager command.\n///\n/// If `project_path` contains a package.json, uses the project's runtime\n/// (based on devEngines.runtime). Otherwise, falls back to the CLI's runtime.\npub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Result<(), Error> {\n    let mut executor = JsExecutor::new(None);\n\n    // Use project runtime if package.json exists, otherwise use CLI runtime\n    let package_json_path = project_path.join(\"package.json\");\n    let runtime = if package_json_path.as_path().exists() {\n        executor.ensure_project_runtime(project_path).await?\n    } else {\n        executor.ensure_cli_runtime().await?\n    };\n\n    let node_bin_prefix = runtime.get_bin_prefix();\n    // Use dedupe_anywhere=true to check if node bin already exists anywhere in PATH\n    let options = PrependOptions { dedupe_anywhere: true };\n    if prepend_to_path_env(&node_bin_prefix, options) {\n        tracing::debug!(\"Set PATH to include {:?}\", node_bin_prefix);\n    }\n\n    Ok(())\n}\n\n/// Build a PackageManager, converting PackageJsonNotFound into a friendly error message.\npub async fn build_package_manager(cwd: &AbsolutePath) -> Result<PackageManager, Error> {\n    match PackageManager::builder(cwd).build_with_default().await {\n        Ok(pm) => Ok(pm),\n        Err(vite_error::Error::WorkspaceError(vite_workspace::Error::PackageJsonNotFound(_))) => {\n            Err(Error::UserMessage(\"No package.json found.\".into()))\n        }\n        Err(e) => Err(e.into()),\n    }\n}\n\n// Category A: Package manager commands\npub mod add;\npub mod dedupe;\npub mod dlx;\npub mod install;\npub mod link;\npub mod outdated;\npub mod pm;\npub mod remove;\npub mod unlink;\npub mod update;\npub mod why;\n\n// Category B: JS Script Commands\npub mod config;\npub mod create;\npub mod migrate;\npub mod staged;\npub mod version;\n\n// Category D: Environment Management\npub mod env;\n\n// Standalone binary command\npub mod vpx;\n\n// Self-Management\npub mod implode;\npub mod upgrade;\n\n// Category C: Local CLI Delegation\npub mod delegate;\npub mod run_or_delegate;\n\n// Re-export command structs for convenient access\npub use add::AddCommand;\npub use dedupe::DedupeCommand;\npub use dlx::DlxCommand;\npub use install::InstallCommand;\npub use link::LinkCommand;\npub use outdated::OutdatedCommand;\npub use remove::RemoveCommand;\npub use unlink::UnlinkCommand;\npub use update::UpdateCommand;\npub use why::WhyCommand;\n\n#[cfg(test)]\nmod tests {\n    use vite_path::AbsolutePathBuf;\n\n    use super::*;\n\n    #[test]\n    fn test_has_vite_plus_in_dev_dependencies() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        std::fs::write(\n            temp_path.join(\"package.json\"),\n            r#\"{ \"devDependencies\": { \"vite-plus\": \"^1.0.0\" } }\"#,\n        )\n        .unwrap();\n        assert!(has_vite_plus_dependency(&temp_path));\n    }\n\n    #[test]\n    fn test_has_vite_plus_in_dependencies() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        std::fs::write(\n            temp_path.join(\"package.json\"),\n            r#\"{ \"dependencies\": { \"vite-plus\": \"^1.0.0\" } }\"#,\n        )\n        .unwrap();\n        assert!(has_vite_plus_dependency(&temp_path));\n    }\n\n    #[test]\n    fn test_no_vite_plus_dependency() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        std::fs::write(\n            temp_path.join(\"package.json\"),\n            r#\"{ \"devDependencies\": { \"vite\": \"^6.0.0\" } }\"#,\n        )\n        .unwrap();\n        assert!(!has_vite_plus_dependency(&temp_path));\n    }\n\n    #[test]\n    fn test_no_package_json() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        assert!(!has_vite_plus_dependency(&temp_path));\n    }\n\n    #[test]\n    fn test_nested_directory_walks_up() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        std::fs::write(\n            temp_path.join(\"package.json\"),\n            r#\"{ \"devDependencies\": { \"vite-plus\": \"^1.0.0\" } }\"#,\n        )\n        .unwrap();\n        let child_dir = temp_path.join(\"child\");\n        std::fs::create_dir(&child_dir).unwrap();\n        let child_path = AbsolutePathBuf::new(child_dir.as_path().to_path_buf()).unwrap();\n        assert!(has_vite_plus_dependency(&child_path));\n    }\n\n    #[test]\n    fn test_empty_package_json() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        std::fs::write(temp_path.join(\"package.json\"), r#\"{}\"#).unwrap();\n        assert!(!has_vite_plus_dependency(&temp_path));\n    }\n\n    #[test]\n    fn test_nested_dir_stops_at_nearest_package_json() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        // Parent has vite-plus\n        std::fs::write(\n            temp_path.join(\"package.json\"),\n            r#\"{ \"devDependencies\": { \"vite-plus\": \"^1.0.0\" } }\"#,\n        )\n        .unwrap();\n        // Child has its own package.json without vite-plus\n        let child_dir = temp_path.join(\"child\");\n        std::fs::create_dir(&child_dir).unwrap();\n        std::fs::write(\n            child_dir.join(\"package.json\"),\n            r#\"{ \"devDependencies\": { \"vite\": \"^6.0.0\" } }\"#,\n        )\n        .unwrap();\n        let child_path = AbsolutePathBuf::new(child_dir.as_path().to_path_buf()).unwrap();\n        // Should find the child's package.json first and return false\n        assert!(!has_vite_plus_dependency(&child_path));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/outdated.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::outdated::{Format, OutdatedCommandOptions};\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Outdated command for checking outdated packages.\n///\n/// This command automatically detects the package manager and translates\n/// the outdated command to the appropriate package manager-specific syntax.\npub struct OutdatedCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl OutdatedCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn execute(\n        self,\n        packages: &[String],\n        long: bool,\n        format: Option<Format>,\n        recursive: bool,\n        filters: Option<&[String]>,\n        workspace_root: bool,\n        prod: bool,\n        dev: bool,\n        no_optional: bool,\n        compatible: bool,\n        sort_by: Option<&str>,\n        global: bool,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let outdated_command_options = OutdatedCommandOptions {\n            packages,\n            long,\n            format,\n            recursive,\n            filters,\n            workspace_root,\n            prod,\n            dev,\n            no_optional,\n            compatible,\n            sort_by,\n            global,\n            pass_through_args,\n        };\n        Ok(package_manager.run_outdated_command(&outdated_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_outdated_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = OutdatedCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/pm.rs",
    "content": "//! Package manager commands (Category A).\n//!\n//! This module handles the `pm` subcommand and the `info` command which are\n//! routed through helper functions. Other PM commands (add, install, remove, etc.)\n//! are implemented as separate command modules with struct-based patterns.\n\nuse std::process::ExitStatus;\n\nuse vite_install::commands::{\n    audit::AuditCommandOptions,\n    cache::CacheCommandOptions,\n    config::ConfigCommandOptions,\n    deprecate::DeprecateCommandOptions,\n    dist_tag::{DistTagCommandOptions, DistTagSubcommand},\n    fund::FundCommandOptions,\n    list::ListCommandOptions,\n    login::LoginCommandOptions,\n    logout::LogoutCommandOptions,\n    owner::OwnerSubcommand,\n    pack::PackCommandOptions,\n    ping::PingCommandOptions,\n    prune::PruneCommandOptions,\n    publish::PublishCommandOptions,\n    rebuild::RebuildCommandOptions,\n    search::SearchCommandOptions,\n    token::TokenSubcommand,\n    view::ViewCommandOptions,\n    whoami::WhoamiCommandOptions,\n};\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::{\n    cli::{ConfigCommands, DistTagCommands, OwnerCommands, PmCommands, TokenCommands},\n    error::Error,\n};\n\n/// Execute the info command.\npub async fn execute_info(\n    cwd: AbsolutePathBuf,\n    package: &str,\n    field: Option<&str>,\n    json: bool,\n    pass_through_args: Option<&[String]>,\n) -> Result<ExitStatus, Error> {\n    prepend_js_runtime_to_path_env(&cwd).await?;\n\n    let package_manager = build_package_manager(&cwd).await?;\n\n    let options = ViewCommandOptions { package, field, json, pass_through_args };\n\n    Ok(package_manager.run_view_command(&options, &cwd).await?)\n}\n\n/// Execute a pm subcommand.\npub async fn execute_pm_subcommand(\n    cwd: AbsolutePathBuf,\n    command: PmCommands,\n) -> Result<ExitStatus, Error> {\n    // Intercept `pm list -g` to use vite-plus managed global packages listing\n    if let PmCommands::List { global: true, json, ref pattern, .. } = command {\n        return crate::commands::env::packages::execute(json, pattern.as_deref()).await;\n    }\n\n    prepend_js_runtime_to_path_env(&cwd).await?;\n\n    let package_manager = build_package_manager(&cwd).await?;\n\n    match command {\n        PmCommands::Prune { prod, no_optional, pass_through_args } => {\n            let options = PruneCommandOptions {\n                prod,\n                no_optional,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_prune_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Pack {\n            recursive,\n            filter,\n            out,\n            pack_destination,\n            pack_gzip_level,\n            json,\n            pass_through_args,\n        } => {\n            let options = PackCommandOptions {\n                recursive,\n                filters: filter.as_deref(),\n                out: out.as_deref(),\n                pack_destination: pack_destination.as_deref(),\n                pack_gzip_level,\n                json,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_pack_command(&options, &cwd).await?)\n        }\n\n        PmCommands::List {\n            pattern,\n            depth,\n            json,\n            long,\n            parseable,\n            prod,\n            dev,\n            no_optional,\n            exclude_peers,\n            only_projects,\n            find_by,\n            recursive,\n            filter,\n            global,\n            pass_through_args,\n        } => {\n            let options = ListCommandOptions {\n                pattern: pattern.as_deref(),\n                depth,\n                json,\n                long,\n                parseable,\n                prod,\n                dev,\n                no_optional,\n                exclude_peers,\n                only_projects,\n                find_by: find_by.as_deref(),\n                recursive,\n                filters: if filter.is_empty() { None } else { Some(&filter) },\n                global,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_list_command(&options, &cwd).await?)\n        }\n\n        PmCommands::View { package, field, json, pass_through_args } => {\n            let options = ViewCommandOptions {\n                package: &package,\n                field: field.as_deref(),\n                json,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_view_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Publish {\n            target,\n            dry_run,\n            tag,\n            access,\n            otp,\n            no_git_checks,\n            publish_branch,\n            report_summary,\n            force,\n            json,\n            recursive,\n            filter,\n            pass_through_args,\n        } => {\n            let options = PublishCommandOptions {\n                target: target.as_deref(),\n                dry_run,\n                tag: tag.as_deref(),\n                access: access.as_deref(),\n                otp: otp.as_deref(),\n                no_git_checks,\n                publish_branch: publish_branch.as_deref(),\n                report_summary,\n                force,\n                json,\n                recursive,\n                filters: filter.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_publish_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Owner(owner_command) => {\n            let subcommand = match owner_command {\n                OwnerCommands::List { package, otp } => OwnerSubcommand::List { package, otp },\n                OwnerCommands::Add { user, package, otp } => {\n                    OwnerSubcommand::Add { user, package, otp }\n                }\n                OwnerCommands::Rm { user, package, otp } => {\n                    OwnerSubcommand::Rm { user, package, otp }\n                }\n            };\n            Ok(package_manager.run_owner_command(&subcommand, &cwd).await?)\n        }\n\n        PmCommands::Cache { subcommand, pass_through_args } => {\n            let options = CacheCommandOptions {\n                subcommand: &subcommand,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_cache_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Config(config_command) => match config_command {\n            ConfigCommands::List { json, global, location } => {\n                let options = ConfigCommandOptions {\n                    subcommand: \"list\",\n                    key: None,\n                    value: None,\n                    json,\n                    location: if global { Some(\"global\") } else { location.as_deref() },\n                    pass_through_args: None,\n                };\n                Ok(package_manager.run_config_command(&options, &cwd).await?)\n            }\n            ConfigCommands::Get { key, json, global, location } => {\n                let options = ConfigCommandOptions {\n                    subcommand: \"get\",\n                    key: Some(key.as_str()),\n                    value: None,\n                    json,\n                    location: if global { Some(\"global\") } else { location.as_deref() },\n                    pass_through_args: None,\n                };\n                Ok(package_manager.run_config_command(&options, &cwd).await?)\n            }\n            ConfigCommands::Set { key, value, json, global, location } => {\n                let options = ConfigCommandOptions {\n                    subcommand: \"set\",\n                    key: Some(key.as_str()),\n                    value: Some(value.as_str()),\n                    json,\n                    location: if global { Some(\"global\") } else { location.as_deref() },\n                    pass_through_args: None,\n                };\n                Ok(package_manager.run_config_command(&options, &cwd).await?)\n            }\n            ConfigCommands::Delete { key, global, location } => {\n                let options = ConfigCommandOptions {\n                    subcommand: \"delete\",\n                    key: Some(key.as_str()),\n                    value: None,\n                    json: false,\n                    location: if global { Some(\"global\") } else { location.as_deref() },\n                    pass_through_args: None,\n                };\n                Ok(package_manager.run_config_command(&options, &cwd).await?)\n            }\n        },\n\n        PmCommands::Login { registry, scope, pass_through_args } => {\n            let options = LoginCommandOptions {\n                registry: registry.as_deref(),\n                scope: scope.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_login_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Logout { registry, scope, pass_through_args } => {\n            let options = LogoutCommandOptions {\n                registry: registry.as_deref(),\n                scope: scope.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_logout_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Whoami { registry, pass_through_args } => {\n            let options = WhoamiCommandOptions {\n                registry: registry.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_whoami_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Token(token_command) => {\n            let subcommand = match token_command {\n                TokenCommands::List { json, registry, pass_through_args } => {\n                    TokenSubcommand::List { json, registry, pass_through_args }\n                }\n                TokenCommands::Create { json, registry, cidr, readonly, pass_through_args } => {\n                    TokenSubcommand::Create { json, registry, cidr, readonly, pass_through_args }\n                }\n                TokenCommands::Revoke { token, registry, pass_through_args } => {\n                    TokenSubcommand::Revoke { token, registry, pass_through_args }\n                }\n            };\n            Ok(package_manager.run_token_command(&subcommand, &cwd).await?)\n        }\n\n        PmCommands::Audit { fix, json, level, production, pass_through_args } => {\n            let options = AuditCommandOptions {\n                fix,\n                json,\n                level: level.as_deref(),\n                production,\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_audit_command(&options, &cwd).await?)\n        }\n\n        PmCommands::DistTag(dist_tag_command) => {\n            let subcommand = match dist_tag_command {\n                DistTagCommands::List { package } => DistTagSubcommand::List { package },\n                DistTagCommands::Add { package_at_version, tag } => {\n                    DistTagSubcommand::Add { package_at_version, tag }\n                }\n                DistTagCommands::Rm { package, tag } => DistTagSubcommand::Rm { package, tag },\n            };\n            let options = DistTagCommandOptions { subcommand, pass_through_args: None };\n            Ok(package_manager.run_dist_tag_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Deprecate { package, message, otp, registry, pass_through_args } => {\n            let options = DeprecateCommandOptions {\n                package: &package,\n                message: &message,\n                otp: otp.as_deref(),\n                registry: registry.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_deprecate_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Search { terms, json, long, registry, pass_through_args } => {\n            let options = SearchCommandOptions {\n                terms: &terms,\n                json,\n                long,\n                registry: registry.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_search_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Rebuild { pass_through_args } => {\n            let options = RebuildCommandOptions { pass_through_args: pass_through_args.as_deref() };\n            Ok(package_manager.run_rebuild_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Fund { json, pass_through_args } => {\n            let options =\n                FundCommandOptions { json, pass_through_args: pass_through_args.as_deref() };\n            Ok(package_manager.run_fund_command(&options, &cwd).await?)\n        }\n\n        PmCommands::Ping { registry, pass_through_args } => {\n            let options = PingCommandOptions {\n                registry: registry.as_deref(),\n                pass_through_args: pass_through_args.as_deref(),\n            };\n            Ok(package_manager.run_ping_command(&options, &cwd).await?)\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use vite_install::commands::add::SaveDependencyType;\n\n    #[test]\n    fn test_save_dependency_type() {\n        assert!(matches!(SaveDependencyType::Dev, SaveDependencyType::Dev));\n        assert!(matches!(SaveDependencyType::Production, SaveDependencyType::Production));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/remove.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::remove::RemoveCommandOptions;\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Remove command for removing packages from dependencies.\n///\n/// This command automatically detects the package manager and translates\n/// the remove command to the appropriate package manager-specific syntax.\npub struct RemoveCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl RemoveCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        packages: &[String],\n        save_dev: bool,\n        save_optional: bool,\n        save_prod: bool,\n        filters: Option<&[String]>,\n        workspace_root: bool,\n        recursive: bool,\n        global: bool,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let remove_command_options = RemoveCommandOptions {\n            packages,\n            filters,\n            workspace_root,\n            recursive,\n            global,\n            save_dev,\n            save_optional,\n            save_prod,\n            pass_through_args,\n        };\n        Ok(package_manager.run_remove_command(&remove_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_remove_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = RemoveCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/run_or_delegate.rs",
    "content": "//! Run command with fallback to package manager when vite-plus is not a dependency.\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::error::Error;\n\n/// Execute `vp run <args>`.\n///\n/// If vite-plus is a dependency, delegate to the local CLI.\n/// If not, fall back to `<pm> run <args>`.\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    if super::has_vite_plus_dependency(&cwd) {\n        tracing::debug!(\"vite-plus is a dependency, delegating to local CLI\");\n        super::delegate::execute(cwd, \"run\", args).await\n    } else {\n        tracing::debug!(\"vite-plus is not a dependency, falling back to package manager run\");\n        super::prepend_js_runtime_to_path_env(&cwd).await?;\n        let package_manager = super::build_package_manager(&cwd).await?;\n        Ok(package_manager.run_script_command(args, &cwd).await?)\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/staged.rs",
    "content": "//! Staged command (Category B: JavaScript Command).\n\nuse std::process::ExitStatus;\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::error::Error;\n\n/// Execute the `staged` command by delegating to local or global vite-plus.\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    super::delegate::execute(cwd, \"staged\", args).await\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/unlink.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::unlink::UnlinkCommandOptions;\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Unlink command for removing package links.\n///\n/// This command automatically detects the package manager and translates\n/// the unlink command to the appropriate package manager-specific syntax.\npub struct UnlinkCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl UnlinkCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        package: Option<&str>,\n        recursive: bool,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let unlink_command_options = UnlinkCommandOptions { package, recursive, pass_through_args };\n        Ok(package_manager.run_unlink_command(&unlink_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_unlink_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = UnlinkCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/update.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::update::UpdateCommandOptions;\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Update command for updating packages to their latest versions.\n///\n/// This command automatically detects the package manager and translates\n/// the update command to the appropriate package manager-specific syntax.\npub struct UpdateCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl UpdateCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn execute(\n        self,\n        packages: &[String],\n        latest: bool,\n        global: bool,\n        recursive: bool,\n        filters: Option<&[String]>,\n        workspace_root: bool,\n        dev: bool,\n        prod: bool,\n        interactive: bool,\n        no_optional: bool,\n        no_save: bool,\n        workspace_only: bool,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let update_command_options = UpdateCommandOptions {\n            packages,\n            latest,\n            global,\n            recursive,\n            filters,\n            workspace_root,\n            dev,\n            prod,\n            interactive,\n            no_optional,\n            no_save,\n            workspace_only,\n            pass_through_args,\n        };\n        Ok(package_manager.run_update_command(&update_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_update_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = UpdateCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/upgrade/install.rs",
    "content": "//! Installation logic for upgrade.\n//!\n//! Handles tarball extraction, dependency installation, symlink swapping,\n//! and version cleanup.\n\nuse std::{\n    io::{Cursor, Read as _},\n    path::Path,\n};\n\nuse flate2::read::GzDecoder;\nuse tar::Archive;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\n\nuse crate::error::Error;\n\n/// Validate that a path from a tarball entry is safe (no path traversal).\n///\n/// Returns `false` if the path contains `..` components or is absolute.\nfn is_safe_tar_path(path: &Path) -> bool {\n    // Also check for Unix-style absolute paths, since tar archives always use forward\n    // slashes and `Path::is_absolute()` on Windows only recognizes `C:\\...` style paths.\n    let starts_with_slash = path.to_string_lossy().starts_with('/');\n    !path.is_absolute()\n        && !starts_with_slash\n        && !path.components().any(|c| matches!(c, std::path::Component::ParentDir))\n}\n\n/// Extract the platform-specific package (binary only).\n///\n/// From the platform tarball, extracts:\n/// - The `vp` binary → `{version_dir}/bin/vp`\n/// - The `vp-shim.exe` trampoline → `{version_dir}/bin/vp-shim.exe` (Windows only)\n///\n/// `.node` files are no longer extracted here — npm installs them\n/// via the platform package's optionalDependencies.\npub async fn extract_platform_package(\n    tgz_data: &[u8],\n    version_dir: &AbsolutePath,\n) -> Result<(), Error> {\n    let bin_dir = version_dir.join(\"bin\");\n    tokio::fs::create_dir_all(&bin_dir).await?;\n\n    let data = tgz_data.to_vec();\n    let bin_dir_clone = bin_dir.clone();\n\n    tokio::task::spawn_blocking(move || {\n        let cursor = Cursor::new(data);\n        let decoder = GzDecoder::new(cursor);\n        let mut archive = Archive::new(decoder);\n\n        for entry_result in archive.entries()? {\n            let mut entry = entry_result?;\n            let path = entry.path()?.to_path_buf();\n\n            // Strip the leading `package/` prefix that npm tarballs have\n            let relative = path.strip_prefix(\"package\").unwrap_or(&path).to_path_buf();\n\n            // Reject paths with traversal components (security)\n            if !is_safe_tar_path(&relative) {\n                continue;\n            }\n\n            let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(\"\");\n\n            if file_name == \"vp\" || file_name == \"vp.exe\" || file_name == \"vp-shim.exe\" {\n                // Binary goes to bin/\n                let target = bin_dir_clone.join(file_name);\n                let mut buf = Vec::new();\n                entry.read_to_end(&mut buf)?;\n                std::fs::write(&target, &buf)?;\n\n                // Set executable permission on Unix\n                #[cfg(unix)]\n                {\n                    use std::os::unix::fs::PermissionsExt;\n                    std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;\n                }\n            }\n        }\n\n        Ok::<(), Error>(())\n    })\n    .await\n    .map_err(|e| Error::Upgrade(format!(\"Task join error: {e}\").into()))??;\n\n    Ok(())\n}\n\n/// Generate a wrapper `package.json` that declares `vite-plus` as a dependency.\n///\n/// This replaces the old approach of extracting the main package tarball.\n/// npm will install `vite-plus` and all its transitive deps via `vp install`.\npub async fn generate_wrapper_package_json(\n    version_dir: &AbsolutePath,\n    version: &str,\n) -> Result<(), Error> {\n    let json = serde_json::json!({\n        \"name\": \"vp-global\",\n        \"version\": version,\n        \"private\": true,\n        \"dependencies\": {\n            \"vite-plus\": version\n        }\n    });\n    let content = serde_json::to_string_pretty(&json)? + \"\\n\";\n    tokio::fs::write(version_dir.join(\"package.json\"), content).await?;\n    Ok(())\n}\n\n/// Install production dependencies using the new version's binary.\n///\n/// Spawns: `{version_dir}/bin/vp install --silent [--registry <url>]` with `CI=true`.\npub async fn install_production_deps(\n    version_dir: &AbsolutePath,\n    registry: Option<&str>,\n) -> Result<(), Error> {\n    let vp_binary = version_dir.join(\"bin\").join(if cfg!(windows) { \"vp.exe\" } else { \"vp\" });\n\n    if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) {\n        return Err(Error::Upgrade(\n            format!(\"New binary not found at {}\", vp_binary.as_path().display()).into(),\n        ));\n    }\n\n    tracing::debug!(\"Running vp install in {}\", version_dir.as_path().display());\n\n    let mut args = vec![\"install\", \"--silent\"];\n    if let Some(registry_url) = registry {\n        args.push(\"--\");\n        args.push(\"--registry\");\n        args.push(registry_url);\n    }\n\n    let output = tokio::process::Command::new(vp_binary.as_path())\n        .args(&args)\n        .current_dir(version_dir)\n        .env(\"CI\", \"true\")\n        .output()\n        .await?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(Error::Upgrade(\n            format!(\n                \"Failed to install production dependencies (exit code: {})\\n{}\",\n                output.status.code().unwrap_or(-1),\n                stderr.trim()\n            )\n            .into(),\n        ));\n    }\n\n    Ok(())\n}\n\n/// Save the current version before swapping, for rollback support.\n///\n/// Reads the `current` symlink target and writes the version to `.previous-version`.\npub async fn save_previous_version(install_dir: &AbsolutePath) -> Result<Option<String>, Error> {\n    let current_link = install_dir.join(\"current\");\n\n    if !tokio::fs::try_exists(&current_link).await.unwrap_or(false) {\n        return Ok(None);\n    }\n\n    let target = tokio::fs::read_link(&current_link).await?;\n    let version = target.file_name().and_then(|n| n.to_str()).map(String::from);\n\n    if let Some(ref v) = version {\n        let prev_file = install_dir.join(\".previous-version\");\n        tokio::fs::write(&prev_file, v).await?;\n        tracing::debug!(\"Saved previous version: {}\", v);\n    }\n\n    Ok(version)\n}\n\n/// Atomically swap the `current` symlink to point to a new version.\n///\n/// On Unix: creates a temp symlink then renames (atomic).\n/// On Windows: removes junction and creates a new one.\npub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Result<(), Error> {\n    let current_link = install_dir.join(\"current\");\n    let version_dir = install_dir.join(version);\n\n    // Verify the version directory exists\n    if !tokio::fs::try_exists(&version_dir).await.unwrap_or(false) {\n        return Err(Error::Upgrade(\n            format!(\"Version directory does not exist: {}\", version_dir.as_path().display()).into(),\n        ));\n    }\n\n    #[cfg(unix)]\n    {\n        // Atomic symlink swap: create temp link, then rename over current\n        let temp_link = install_dir.join(\"current.new\");\n\n        // Remove temp link if it exists from a previous failed attempt\n        let _ = tokio::fs::remove_file(&temp_link).await;\n\n        tokio::fs::symlink(version, &temp_link).await?;\n        tokio::fs::rename(&temp_link, &current_link).await?;\n    }\n\n    #[cfg(windows)]\n    {\n        // Windows: junction swap (not atomic)\n        // Remove whatever exists at current_link — could be a junction, symlink, or directory.\n        // We don't rely on junction::exists() since it may not detect junctions created by\n        // cmd /c mklink /J (used by install.ps1).\n        if current_link.as_path().exists() {\n            // std::fs::remove_dir works on junctions/symlinks without removing target contents\n            if let Err(e) = std::fs::remove_dir(&current_link) {\n                tracing::debug!(\"remove_dir failed ({}), trying junction::delete\", e);\n                junction::delete(&current_link).map_err(|e| {\n                    Error::Upgrade(\n                        format!(\n                            \"Failed to remove existing junction at {}: {e}\",\n                            current_link.as_path().display()\n                        )\n                        .into(),\n                    )\n                })?;\n            }\n        }\n\n        junction::create(&version_dir, &current_link).map_err(|e| {\n            Error::Upgrade(\n                format!(\n                    \"Failed to create junction at {}: {e}\\nTry removing it manually and run again.\",\n                    current_link.as_path().display()\n                )\n                .into(),\n            )\n        })?;\n    }\n\n    tracing::debug!(\"Swapped current → {}\", version);\n    Ok(())\n}\n\n/// Refresh shims by running `vp env setup --refresh` with the new binary.\npub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> {\n    let vp_binary =\n        install_dir.join(\"current\").join(\"bin\").join(if cfg!(windows) { \"vp.exe\" } else { \"vp\" });\n\n    if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) {\n        tracing::warn!(\n            \"New binary not found at {}, skipping shim refresh\",\n            vp_binary.as_path().display()\n        );\n        return Ok(());\n    }\n\n    tracing::debug!(\"Refreshing shims...\");\n\n    let output = tokio::process::Command::new(vp_binary.as_path())\n        .args([\"env\", \"setup\", \"--refresh\"])\n        .output()\n        .await?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        tracing::warn!(\n            \"Shim refresh exited with code {}, continuing anyway\\n{}\",\n            output.status.code().unwrap_or(-1),\n            stderr.trim()\n        );\n    }\n\n    Ok(())\n}\n\n/// Clean up old version directories, keeping at most `max_keep` versions.\n///\n/// Sorts by creation time (newest first, matching install.sh behavior) and removes\n/// the oldest beyond the limit. Protected versions are never removed, even if they\n/// fall outside the keep limit (e.g., the active version after a downgrade).\npub async fn cleanup_old_versions(\n    install_dir: &AbsolutePath,\n    max_keep: usize,\n    protected_versions: &[&str],\n) -> Result<(), Error> {\n    let mut versions: Vec<(std::time::SystemTime, AbsolutePathBuf)> = Vec::new();\n\n    let mut entries = tokio::fs::read_dir(install_dir).await?;\n    while let Some(entry) = entries.next_entry().await? {\n        let name = entry.file_name();\n        let name_str = name.to_string_lossy();\n\n        // Only consider entries that parse as semver\n        if node_semver::Version::parse(&name_str).is_ok() {\n            let metadata = entry.metadata().await?;\n            // Use creation time (birth time), fallback to modified time\n            let time = metadata.created().unwrap_or_else(|_| {\n                metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH)\n            });\n            let path = AbsolutePathBuf::new(entry.path()).ok_or_else(|| {\n                Error::Upgrade(format!(\"Invalid absolute path: {}\", entry.path().display()).into())\n            })?;\n            versions.push((time, path));\n        }\n    }\n\n    // Sort newest first (by creation time, matching install.sh)\n    versions.sort_by(|a, b| b.0.cmp(&a.0));\n\n    // Remove versions beyond the keep limit, but never remove protected versions\n    for (_time, path) in versions.into_iter().skip(max_keep) {\n        let name = path.as_path().file_name().and_then(|n| n.to_str()).unwrap_or(\"\");\n        if protected_versions.contains(&name) {\n            tracing::debug!(\"Skipping protected version: {}\", name);\n            continue;\n        }\n        tracing::debug!(\"Cleaning up old version: {}\", path.as_path().display());\n        if let Err(e) = tokio::fs::remove_dir_all(&path).await {\n            tracing::warn!(\"Failed to remove {}: {}\", path.as_path().display(), e);\n        }\n    }\n\n    Ok(())\n}\n\n/// Read the previous version from `.previous-version` file.\npub async fn read_previous_version(install_dir: &AbsolutePath) -> Result<Option<String>, Error> {\n    let prev_file = install_dir.join(\".previous-version\");\n\n    if !tokio::fs::try_exists(&prev_file).await.unwrap_or(false) {\n        return Ok(None);\n    }\n\n    let content = tokio::fs::read_to_string(&prev_file).await?;\n    let version = content.trim().to_string();\n\n    if version.is_empty() { Ok(None) } else { Ok(Some(version)) }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_is_safe_tar_path_normal() {\n        assert!(is_safe_tar_path(Path::new(\"dist/index.js\")));\n        assert!(is_safe_tar_path(Path::new(\"bin/vp\")));\n        assert!(is_safe_tar_path(Path::new(\"package.json\")));\n        assert!(is_safe_tar_path(Path::new(\"templates/react/index.ts\")));\n    }\n\n    #[test]\n    fn test_is_safe_tar_path_traversal() {\n        assert!(!is_safe_tar_path(Path::new(\"../etc/passwd\")));\n        assert!(!is_safe_tar_path(Path::new(\"dist/../../etc/passwd\")));\n        assert!(!is_safe_tar_path(Path::new(\"..\")));\n    }\n\n    #[test]\n    fn test_is_safe_tar_path_absolute() {\n        assert!(!is_safe_tar_path(Path::new(\"/etc/passwd\")));\n        assert!(!is_safe_tar_path(Path::new(\"/usr/bin/vp\")));\n    }\n\n    #[tokio::test]\n    async fn test_cleanup_preserves_active_downgraded_version() {\n        let temp = tempfile::tempdir().unwrap();\n        let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();\n\n        // Create 7 version directories with staggered creation times.\n        // Simulate: installed 0.1-0.7 in order, then rolled back to 0.2.0\n        for v in [\"0.1.0\", \"0.2.0\", \"0.3.0\", \"0.4.0\", \"0.5.0\", \"0.6.0\", \"0.7.0\"] {\n            tokio::fs::create_dir(install_dir.join(v)).await.unwrap();\n            // Small delay to ensure distinct creation times\n            tokio::time::sleep(std::time::Duration::from_millis(10)).await;\n        }\n\n        // Simulate rollback: current points to 0.2.0 (low semver rank)\n        #[cfg(unix)]\n        tokio::fs::symlink(\"0.2.0\", install_dir.join(\"current\")).await.unwrap();\n\n        // Cleanup keeping top 5, with 0.2.0 protected (the active version)\n        cleanup_old_versions(&install_dir, 5, &[\"0.2.0\"]).await.unwrap();\n\n        // 0.2.0 is the active version — it MUST survive cleanup\n        assert!(\n            tokio::fs::try_exists(install_dir.join(\"0.2.0\")).await.unwrap(),\n            \"Active version 0.2.0 was deleted by cleanup\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_cleanup_sorts_by_creation_time_not_semver() {\n        let temp = tempfile::tempdir().unwrap();\n        let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();\n\n        // Create versions in non-semver order with creation times:\n        // 0.5.0 (oldest), 0.1.0, 0.3.0, 0.7.0, 0.2.0, 0.6.0 (newest)\n        for v in [\"0.5.0\", \"0.1.0\", \"0.3.0\", \"0.7.0\", \"0.2.0\", \"0.6.0\"] {\n            tokio::fs::create_dir(install_dir.join(v)).await.unwrap();\n            tokio::time::sleep(std::time::Duration::from_millis(10)).await;\n        }\n\n        // Keep top 4 by creation time → keep 0.6.0, 0.2.0, 0.7.0, 0.3.0\n        // Remove 0.1.0 and 0.5.0 (oldest by creation time)\n        cleanup_old_versions(&install_dir, 4, &[]).await.unwrap();\n\n        // The 4 newest by creation time should survive\n        assert!(tokio::fs::try_exists(install_dir.join(\"0.6.0\")).await.unwrap());\n        assert!(tokio::fs::try_exists(install_dir.join(\"0.2.0\")).await.unwrap());\n        assert!(tokio::fs::try_exists(install_dir.join(\"0.7.0\")).await.unwrap());\n        assert!(tokio::fs::try_exists(install_dir.join(\"0.3.0\")).await.unwrap());\n\n        // The 2 oldest by creation time should be removed\n        assert!(\n            !tokio::fs::try_exists(install_dir.join(\"0.5.0\")).await.unwrap(),\n            \"0.5.0 (oldest by creation time) should have been removed\"\n        );\n        assert!(\n            !tokio::fs::try_exists(install_dir.join(\"0.1.0\")).await.unwrap(),\n            \"0.1.0 (second oldest by creation time) should have been removed\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_cleanup_old_versions_with_nonexistent_dir() {\n        // Verifies that cleanup_old_versions propagates errors on non-existent dir.\n        // In the real flow, such errors from post-swap operations should be non-fatal.\n        let non_existent =\n            AbsolutePathBuf::new(std::env::temp_dir().join(\"non-existent-upgrade-test-dir\"))\n                .unwrap();\n        let result = cleanup_old_versions(&non_existent, 5, &[]).await;\n        assert!(result.is_err(), \"cleanup_old_versions should error on non-existent dir\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/upgrade/integrity.rs",
    "content": "//! Integrity verification for downloaded tarballs.\n//!\n//! Verifies SHA-512 integrity using the Subresource Integrity (SRI) format\n//! that npm registries provide: `sha512-{base64}`.\n\nuse sha2::{Digest, Sha512};\n\nuse crate::error::Error;\n\n/// Verify the integrity of data against an SRI hash.\n///\n/// Parses the SRI format `sha512-{base64}`, computes the SHA-512 hash\n/// of the data, base64-encodes it, and compares.\npub fn verify_integrity(data: &[u8], expected_sri: &str) -> Result<(), Error> {\n    let expected_b64 = expected_sri\n        .strip_prefix(\"sha512-\")\n        .ok_or_else(|| Error::UnsupportedIntegrity(expected_sri.into()))?;\n\n    let mut hasher = Sha512::new();\n    hasher.update(data);\n    let actual_b64 = base64_simd::STANDARD.encode_to_string(hasher.finalize());\n\n    if actual_b64 != expected_b64 {\n        return Err(Error::IntegrityMismatch {\n            expected: expected_sri.into(),\n            actual: format!(\"sha512-{actual_b64}\").into(),\n        });\n    }\n\n    tracing::debug!(\"Integrity verification successful\");\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_verify_integrity_valid() {\n        let data = b\"Hello, World!\";\n        let mut hasher = Sha512::new();\n        hasher.update(data);\n        let hash = base64_simd::STANDARD.encode_to_string(hasher.finalize());\n        let sri = format!(\"sha512-{hash}\");\n\n        assert!(verify_integrity(data, &sri).is_ok());\n    }\n\n    #[test]\n    fn test_verify_integrity_mismatch() {\n        let data = b\"Hello, World!\";\n        let sri = \"sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\";\n\n        let err = verify_integrity(data, sri).unwrap_err();\n        assert!(matches!(err, Error::IntegrityMismatch { .. }));\n    }\n\n    #[test]\n    fn test_verify_integrity_unsupported_format() {\n        let data = b\"Hello, World!\";\n        let sri = \"sha256-abc123\";\n\n        let err = verify_integrity(data, sri).unwrap_err();\n        assert!(matches!(err, Error::UnsupportedIntegrity(_)));\n    }\n\n    #[test]\n    fn test_verify_integrity_no_prefix() {\n        let data = b\"Hello, World!\";\n        let sri = \"not-a-valid-sri\";\n\n        let err = verify_integrity(data, sri).unwrap_err();\n        assert!(matches!(err, Error::UnsupportedIntegrity(_)));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/upgrade/mod.rs",
    "content": "//! Upgrade command for the vp CLI.\n//!\n//! Downloads and installs a new version of the CLI from the npm registry\n//! with SHA-512 integrity verification.\n\nmod install;\nmod integrity;\nmod platform;\nmod registry;\n\nuse std::process::ExitStatus;\n\nuse owo_colors::OwoColorize;\nuse vite_install::request::HttpClient;\nuse vite_path::AbsolutePathBuf;\nuse vite_shared::output;\n\nuse crate::{commands::env::config::get_vite_plus_home, error::Error};\n\n/// Options for the upgrade command.\npub struct UpgradeOptions {\n    /// Target version (e.g., \"0.2.0\"). None means use the tag.\n    pub version: Option<String>,\n    /// npm dist-tag (default: \"latest\")\n    pub tag: String,\n    /// Check for updates without installing\n    pub check: bool,\n    /// Revert to previous version\n    pub rollback: bool,\n    /// Force reinstall even if already on the target version\n    pub force: bool,\n    /// Suppress output\n    pub silent: bool,\n    /// Custom npm registry URL\n    pub registry: Option<String>,\n}\n\n/// Maximum number of old versions to keep.\nconst MAX_VERSIONS_KEEP: usize = 5;\n\n/// Execute the upgrade command.\n#[allow(clippy::print_stdout, clippy::print_stderr)]\npub async fn execute(options: UpgradeOptions) -> Result<ExitStatus, Error> {\n    let install_dir = get_vite_plus_home()?;\n\n    // Handle --rollback\n    if options.rollback {\n        return execute_rollback(&install_dir, options.silent).await;\n    }\n\n    // Step 1: Detect platform\n    let platform_suffix = platform::detect_platform_suffix()?;\n    tracing::debug!(\"Platform: {}\", platform_suffix);\n\n    // Step 2: Determine version to resolve\n    let version_or_tag = options.version.as_deref().unwrap_or(&options.tag);\n\n    if !options.silent {\n        output::info(\"checking for updates...\");\n    }\n\n    // Step 3: Resolve version from npm registry\n    let resolved =\n        registry::resolve_version(version_or_tag, &platform_suffix, options.registry.as_deref())\n            .await?;\n\n    let current_version = env!(\"CARGO_PKG_VERSION\");\n\n    if !options.silent {\n        output::info(&format!(\n            \"found vite-plus@{} (current: {})\",\n            resolved.version, current_version\n        ));\n    }\n\n    // Step 4: Handle --check (report and exit)\n    if options.check {\n        if resolved.version == current_version {\n            println!(\"\\n{} Already up to date ({})\", output::CHECK.green(), current_version);\n        } else {\n            println!(\"Update available: {} \\u{2192} {}\", current_version, resolved.version);\n            println!(\"Run `vp upgrade` to update.\");\n        }\n        return Ok(ExitStatus::default());\n    }\n\n    // Step 5: Handle already up-to-date\n    if resolved.version == current_version && !options.force {\n        if !options.silent {\n            println!(\"\\n{} Already up to date ({})\", output::CHECK.green(), current_version);\n        }\n        return Ok(ExitStatus::default());\n    }\n\n    if !options.silent {\n        output::info(&format!(\n            \"downloading vite-plus@{} for {}...\",\n            resolved.version, platform_suffix\n        ));\n    }\n\n    // Step 6: Download platform tarball (main package is installed via npm)\n    let client = HttpClient::new();\n\n    let platform_data = client\n        .get_bytes(&resolved.platform_tarball_url)\n        .await\n        .map_err(|e| Error::Upgrade(format!(\"Failed to download platform package: {e}\").into()))?;\n\n    // Step 7: Verify integrity\n    integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?;\n\n    if !options.silent {\n        output::info(\"installing...\");\n    }\n\n    // Step 8: Create version directory\n    let version_dir = install_dir.join(&resolved.version);\n    tokio::fs::create_dir_all(&version_dir).await?;\n\n    // Step 9: Extract platform binary and install via npm\n    let result = install_platform_and_main(\n        &platform_data,\n        &version_dir,\n        &install_dir,\n        &resolved.version,\n        current_version,\n        options.silent,\n        options.registry.as_deref(),\n    )\n    .await;\n\n    // On failure, clean up the version directory\n    if result.is_err() {\n        tracing::debug!(\"Cleaning up failed install at {}\", version_dir.as_path().display());\n        let _ = tokio::fs::remove_dir_all(&version_dir).await;\n    }\n\n    result\n}\n\n/// Core installation logic, separated for error cleanup.\n#[allow(clippy::print_stdout, clippy::print_stderr)]\nasync fn install_platform_and_main(\n    platform_data: &[u8],\n    version_dir: &AbsolutePathBuf,\n    install_dir: &AbsolutePathBuf,\n    new_version: &str,\n    current_version: &str,\n    silent: bool,\n    registry: Option<&str>,\n) -> Result<ExitStatus, Error> {\n    // Extract platform package (binary only; .node files installed via npm optionalDeps)\n    install::extract_platform_package(platform_data, version_dir).await?;\n\n    // Verify binary was extracted\n    let binary_name = if cfg!(windows) { \"vp.exe\" } else { \"vp\" };\n    let binary_path = version_dir.join(\"bin\").join(binary_name);\n    if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) {\n        return Err(Error::Upgrade(\n            \"Binary not found after extraction. The download may be corrupted.\".into(),\n        ));\n    }\n\n    // Generate wrapper package.json that declares vite-plus as a dependency\n    install::generate_wrapper_package_json(version_dir, new_version).await?;\n\n    // Install production dependencies (npm installs vite-plus + all transitive deps)\n    install::install_production_deps(version_dir, registry).await?;\n\n    // Save previous version for rollback\n    let previous_version = install::save_previous_version(install_dir).await?;\n    tracing::debug!(\"Previous version: {:?}\", previous_version);\n\n    // Swap current link — POINT OF NO RETURN\n    install::swap_current_link(install_dir, new_version).await?;\n\n    // Post-swap operations: non-fatal (the update already succeeded)\n    if let Err(e) = install::refresh_shims(install_dir).await {\n        output::warn(&format!(\"Shim refresh failed (non-fatal): {e}\"));\n    }\n\n    let mut protected = vec![new_version];\n    if let Some(ref prev) = previous_version {\n        protected.push(prev.as_str());\n    }\n    if let Err(e) = install::cleanup_old_versions(install_dir, MAX_VERSIONS_KEEP, &protected).await\n    {\n        output::warn(&format!(\"Old version cleanup failed (non-fatal): {e}\"));\n    }\n\n    if !silent {\n        println!(\n            \"\\n{} Updated vite-plus from {} {} {}\",\n            output::CHECK.green(),\n            current_version,\n            output::ARROW,\n            new_version\n        );\n        println!(\n            \"\\n  Release notes: https://github.com/voidzero-dev/vite-plus/releases/tag/v{}\",\n            new_version\n        );\n    }\n\n    Ok(ExitStatus::default())\n}\n\n/// Execute rollback to the previous version.\n#[allow(clippy::print_stdout, clippy::print_stderr)]\nasync fn execute_rollback(\n    install_dir: &AbsolutePathBuf,\n    silent: bool,\n) -> Result<ExitStatus, Error> {\n    let previous = install::read_previous_version(install_dir)\n        .await?\n        .ok_or_else(|| Error::Upgrade(\"No previous version found. Cannot rollback.\".into()))?;\n\n    // Verify the version directory still exists\n    let prev_dir = install_dir.join(&previous);\n    if !tokio::fs::try_exists(&prev_dir).await.unwrap_or(false) {\n        return Err(Error::Upgrade(\n            format!(\"Previous version directory ({}) no longer exists. Cannot rollback.\", previous)\n                .into(),\n        ));\n    }\n\n    if !silent {\n        let current_version = env!(\"CARGO_PKG_VERSION\");\n        output::info(\"rolling back to previous version...\");\n        output::info(&format!(\"switching from {} {} {}\", current_version, output::ARROW, previous));\n    }\n\n    // Save the current version as the new \"previous\" before swapping\n    install::save_previous_version(install_dir).await?;\n\n    // Swap to the previous version\n    install::swap_current_link(install_dir, &previous).await?;\n\n    // Refresh shims\n    install::refresh_shims(install_dir).await?;\n\n    if !silent {\n        println!(\"\\n{} Rolled back to {}\", output::CHECK.green(), previous);\n    }\n\n    Ok(ExitStatus::default())\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/upgrade/platform.rs",
    "content": "//! Platform detection for upgrade.\n//!\n//! Detects the current platform and returns the npm package suffix\n//! used to find the correct platform-specific binary package.\n\nuse crate::error::Error;\n\n/// Detect the current platform suffix for npm package naming.\n///\n/// Returns strings like `darwin-arm64`, `linux-x64-gnu`, `linux-arm64-musl`, `win32-x64-msvc`.\npub fn detect_platform_suffix() -> Result<String, Error> {\n    let os_name = if cfg!(target_os = \"macos\") {\n        \"darwin\"\n    } else if cfg!(target_os = \"linux\") {\n        \"linux\"\n    } else if cfg!(target_os = \"windows\") {\n        \"win32\"\n    } else {\n        return Err(Error::Upgrade(\n            format!(\"Unsupported operating system: {}\", std::env::consts::OS).into(),\n        ));\n    };\n\n    let arch_name = if cfg!(target_arch = \"x86_64\") {\n        \"x64\"\n    } else if cfg!(target_arch = \"aarch64\") {\n        \"arm64\"\n    } else {\n        return Err(Error::Upgrade(\n            format!(\"Unsupported architecture: {}\", std::env::consts::ARCH).into(),\n        ));\n    };\n\n    if os_name == \"linux\" {\n        let libc = if cfg!(target_env = \"musl\") { \"musl\" } else { \"gnu\" };\n        Ok(format!(\"{os_name}-{arch_name}-{libc}\"))\n    } else if os_name == \"win32\" {\n        Ok(format!(\"{os_name}-{arch_name}-msvc\"))\n    } else {\n        Ok(format!(\"{os_name}-{arch_name}\"))\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_detect_platform_suffix() {\n        let suffix = detect_platform_suffix().unwrap();\n\n        // Should be non-empty and contain a dash\n        assert!(!suffix.is_empty());\n        assert!(suffix.contains('-'));\n\n        // Should match the current platform\n        #[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\n        assert_eq!(suffix, \"darwin-arm64\");\n\n        #[cfg(all(target_os = \"macos\", target_arch = \"x86_64\"))]\n        assert_eq!(suffix, \"darwin-x64\");\n\n        #[cfg(all(target_os = \"linux\", target_arch = \"x86_64\", not(target_env = \"musl\")))]\n        assert_eq!(suffix, \"linux-x64-gnu\");\n\n        #[cfg(all(target_os = \"linux\", target_arch = \"x86_64\", target_env = \"musl\"))]\n        assert_eq!(suffix, \"linux-x64-musl\");\n\n        #[cfg(all(target_os = \"linux\", target_arch = \"aarch64\", not(target_env = \"musl\")))]\n        assert_eq!(suffix, \"linux-arm64-gnu\");\n\n        #[cfg(all(target_os = \"windows\", target_arch = \"x86_64\"))]\n        assert_eq!(suffix, \"win32-x64-msvc\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/upgrade/registry.rs",
    "content": "//! npm registry client for version resolution.\n//!\n//! Queries the npm registry to resolve versions and get tarball URLs\n//! with integrity hashes for both the main package and platform-specific package.\n\nuse serde::Deserialize;\nuse vite_install::{config::npm_registry, request::HttpClient};\n\nuse crate::error::Error;\n\n/// npm package version metadata (subset of fields we need).\n#[derive(Debug, Deserialize)]\npub struct PackageVersionMetadata {\n    pub version: String,\n    pub dist: DistInfo,\n}\n\n/// Distribution info from npm registry.\n#[derive(Debug, Deserialize)]\npub struct DistInfo {\n    pub tarball: String,\n    pub integrity: String,\n}\n\n/// Resolved version info with URLs and integrity for the platform package.\n#[derive(Debug)]\npub struct ResolvedVersion {\n    pub version: String,\n    pub platform_tarball_url: String,\n    pub platform_integrity: String,\n}\n\nconst MAIN_PACKAGE_NAME: &str = \"vite-plus\";\nconst PLATFORM_PACKAGE_SCOPE: &str = \"@voidzero-dev\";\nconst CLI_PACKAGE_NAME_PREFIX: &str = \"vite-plus-cli\";\n\n/// Resolve a version from the npm registry.\n///\n/// Makes two HTTP calls:\n/// 1. Main package metadata to resolve version tags (e.g., \"latest\" → \"1.2.3\")\n/// 2. CLI platform package metadata to get tarball URL and integrity\npub async fn resolve_version(\n    version_or_tag: &str,\n    platform_suffix: &str,\n    registry_override: Option<&str>,\n) -> Result<ResolvedVersion, Error> {\n    let default_registry = npm_registry();\n    let registry_raw = registry_override.unwrap_or(&default_registry);\n    let registry = registry_raw.trim_end_matches('/');\n    let client = HttpClient::new();\n\n    // Step 1: Fetch main package metadata to resolve version\n    let main_url = format!(\"{registry}/{MAIN_PACKAGE_NAME}/{version_or_tag}\");\n    tracing::debug!(\"Fetching main package metadata: {}\", main_url);\n\n    let main_meta: PackageVersionMetadata = client.get_json(&main_url).await.map_err(|e| {\n        Error::Upgrade(format!(\"Failed to fetch package metadata from {main_url}: {e}\").into())\n    })?;\n\n    // Step 2: Query CLI platform package directly\n    let cli_package_name =\n        format!(\"{PLATFORM_PACKAGE_SCOPE}/{CLI_PACKAGE_NAME_PREFIX}-{platform_suffix}\");\n    let cli_url = format!(\"{registry}/{cli_package_name}/{}\", main_meta.version);\n    tracing::debug!(\"Fetching CLI package metadata: {}\", cli_url);\n\n    let cli_meta: PackageVersionMetadata = client.get_json(&cli_url).await.map_err(|e| {\n        Error::Upgrade(\n            format!(\n                \"Failed to fetch CLI package metadata from {cli_url}: {e}. \\\n                     Your platform ({platform_suffix}) may not be supported.\"\n            )\n            .into(),\n        )\n    })?;\n\n    Ok(ResolvedVersion {\n        version: main_meta.version,\n        platform_tarball_url: cli_meta.dist.tarball,\n        platform_integrity: cli_meta.dist.integrity,\n    })\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_cli_package_name_construction() {\n        let suffix = \"darwin-arm64\";\n        let name = format!(\"{PLATFORM_PACKAGE_SCOPE}/{CLI_PACKAGE_NAME_PREFIX}-{suffix}\");\n        assert_eq!(name, \"@voidzero-dev/vite-plus-cli-darwin-arm64\");\n    }\n\n    #[test]\n    fn test_all_platform_suffixes_match_published_cli_packages() {\n        // These are the actual published CLI package suffixes\n        // (from packages/cli/publish-native-addons.ts RUST_TARGETS keys)\n        let published_suffixes = [\n            \"darwin-arm64\",\n            \"darwin-x64\",\n            \"linux-arm64-gnu\",\n            \"linux-x64-gnu\",\n            \"win32-arm64-msvc\",\n            \"win32-x64-msvc\",\n        ];\n\n        let published_packages: Vec<String> = published_suffixes\n            .iter()\n            .map(|s| format!(\"{PLATFORM_PACKAGE_SCOPE}/{CLI_PACKAGE_NAME_PREFIX}-{s}\"))\n            .collect();\n\n        // All known platform suffixes that detect_platform_suffix() can return\n        let detection_suffixes = [\n            \"darwin-arm64\",\n            \"darwin-x64\",\n            \"linux-arm64-gnu\",\n            \"linux-x64-gnu\",\n            \"linux-arm64-musl\",\n            \"linux-x64-musl\",\n            \"win32-arm64-msvc\",\n            \"win32-x64-msvc\",\n        ];\n\n        for suffix in &detection_suffixes {\n            let package_name =\n                format!(\"{PLATFORM_PACKAGE_SCOPE}/{CLI_PACKAGE_NAME_PREFIX}-{suffix}\");\n            // musl variants are not published, so skip them\n            if suffix.contains(\"musl\") {\n                continue;\n            }\n            assert!(\n                published_packages.contains(&package_name),\n                \"Platform suffix '{suffix}' produces CLI package name '{package_name}' \\\n                 which does not match any published CLI package\"\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/version.rs",
    "content": "//! Version command.\n\nuse std::{\n    collections::BTreeMap,\n    fs,\n    path::{Path, PathBuf},\n    process::ExitStatus,\n};\n\nuse owo_colors::OwoColorize;\nuse serde::Deserialize;\nuse vite_install::get_package_manager_type_and_version;\nuse vite_path::AbsolutePathBuf;\nuse vite_workspace::find_workspace_root;\n\nuse crate::{commands::env::config::resolve_version, error::Error, help};\n\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct PackageJson {\n    version: String,\n    #[serde(default)]\n    bundled_versions: BTreeMap<String, String>,\n}\n\n#[derive(Debug)]\nstruct LocalVitePlus {\n    version: String,\n    package_dir: PathBuf,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct ToolSpec {\n    display_name: &'static str,\n    package_name: &'static str,\n    bundled_version_key: Option<&'static str>,\n}\n\nconst TOOL_SPECS: [ToolSpec; 7] = [\n    ToolSpec {\n        display_name: \"vite\",\n        package_name: \"@voidzero-dev/vite-plus-core\",\n        bundled_version_key: Some(\"vite\"),\n    },\n    ToolSpec {\n        display_name: \"rolldown\",\n        package_name: \"@voidzero-dev/vite-plus-core\",\n        bundled_version_key: Some(\"rolldown\"),\n    },\n    ToolSpec {\n        display_name: \"vitest\",\n        package_name: \"@voidzero-dev/vite-plus-test\",\n        bundled_version_key: Some(\"vitest\"),\n    },\n    ToolSpec { display_name: \"oxfmt\", package_name: \"oxfmt\", bundled_version_key: None },\n    ToolSpec { display_name: \"oxlint\", package_name: \"oxlint\", bundled_version_key: None },\n    ToolSpec {\n        display_name: \"oxlint-tsgolint\",\n        package_name: \"oxlint-tsgolint\",\n        bundled_version_key: None,\n    },\n    ToolSpec {\n        display_name: \"tsdown\",\n        package_name: \"@voidzero-dev/vite-plus-core\",\n        bundled_version_key: Some(\"tsdown\"),\n    },\n];\n\nconst NOT_FOUND: &str = \"Not found\";\n\nfn read_package_json(package_json_path: &Path) -> Option<PackageJson> {\n    let content = fs::read_to_string(package_json_path).ok()?;\n    serde_json::from_str(&content).ok()\n}\n\nfn find_local_vite_plus(start: &Path) -> Option<LocalVitePlus> {\n    let mut current = Some(start);\n    while let Some(dir) = current {\n        let package_json_path = dir.join(\"node_modules\").join(\"vite-plus\").join(\"package.json\");\n        if let Some(pkg) = read_package_json(&package_json_path) {\n            let package_dir = package_json_path.parent()?.to_path_buf();\n            // Follow symlinks (pnpm links node_modules/vite-plus -> node_modules/.pnpm/.../vite-plus)\n            // so parent traversal can discover colocated dependency links.\n            let package_dir = fs::canonicalize(&package_dir).unwrap_or(package_dir);\n            return Some(LocalVitePlus { version: pkg.version, package_dir });\n        }\n        current = dir.parent();\n    }\n    None\n}\n\nfn resolve_package_json(base_dir: &Path, package_name: &str) -> Option<PackageJson> {\n    let mut current = Some(base_dir);\n    while let Some(dir) = current {\n        let package_json_path = dir.join(\"node_modules\").join(package_name).join(\"package.json\");\n        if let Some(pkg) = read_package_json(&package_json_path) {\n            return Some(pkg);\n        }\n        current = dir.parent();\n    }\n    None\n}\n\nfn resolve_tool_version(local: &LocalVitePlus, tool: ToolSpec) -> Option<String> {\n    let pkg = resolve_package_json(&local.package_dir, tool.package_name)?;\n    if let Some(key) = tool.bundled_version_key\n        && let Some(version) = pkg.bundled_versions.get(key)\n    {\n        return Some(version.clone());\n    }\n    Some(pkg.version)\n}\n\nfn accent(text: &str) -> String {\n    if help::should_style_help() { text.bright_blue().to_string() } else { text.to_string() }\n}\n\nfn print_rows(title: &str, rows: &[(&str, String)]) {\n    println!(\"{}\", help::render_heading(title));\n    let label_width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0);\n    for (label, value) in rows {\n        let padding = \" \".repeat(label_width.saturating_sub(label.chars().count()));\n        println!(\"  {}{}  {value}\", accent(label), padding);\n    }\n}\n\nfn format_version(version: Option<String>) -> String {\n    match version {\n        Some(v) => format!(\"v{v}\"),\n        None => NOT_FOUND.to_string(),\n    }\n}\n\nasync fn get_node_version_info(cwd: &AbsolutePathBuf) -> Option<(String, String)> {\n    // Try the full managed resolution chain\n    if let Ok(resolution) = resolve_version(cwd).await {\n        return Some((resolution.version, resolution.source));\n    }\n\n    // Fallback: detect system Node version (with VITE_PLUS_BYPASS to avoid hitting the shim)\n    let version = detect_system_node_version()?;\n    Some((version, \"system\".to_string()))\n}\n\nfn detect_system_node_version() -> Option<String> {\n    let output = std::process::Command::new(\"node\")\n        .arg(\"--version\")\n        .env(vite_shared::env_vars::VITE_PLUS_BYPASS, \"1\")\n        .output()\n        .ok()?;\n    if !output.status.success() {\n        return None;\n    }\n    let version = String::from_utf8(output.stdout).ok()?;\n    let version = version.trim().strip_prefix('v').unwrap_or(version.trim());\n    if version.is_empty() {\n        return None;\n    }\n    Some(version.to_string())\n}\n\n/// Execute the `--version` command.\npub async fn execute(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    println!(\"{}\", vite_shared::header::vite_plus_header());\n    println!();\n\n    println!(\"vp v{}\", env!(\"CARGO_PKG_VERSION\"));\n    println!();\n\n    // Local vite-plus and tools\n    let local = find_local_vite_plus(cwd.as_path());\n    print_rows(\n        \"Local vite-plus\",\n        &[(\"vite-plus\", format_version(local.as_ref().map(|pkg| pkg.version.clone())))],\n    );\n    println!();\n\n    let tool_rows = TOOL_SPECS\n        .iter()\n        .map(|tool| {\n            let version =\n                local.as_ref().and_then(|local_pkg| resolve_tool_version(local_pkg, *tool));\n            (tool.display_name, format_version(version))\n        })\n        .collect::<Vec<_>>();\n    print_rows(\"Tools\", &tool_rows);\n    println!();\n\n    // Environment info\n    let package_manager_info = find_workspace_root(&cwd)\n        .ok()\n        .and_then(|(root, _)| {\n            get_package_manager_type_and_version(&root, None)\n                .ok()\n                .map(|(pm, v, _)| format!(\"{pm} v{v}\"))\n        })\n        .unwrap_or(NOT_FOUND.to_string());\n\n    let node_info = get_node_version_info(&cwd)\n        .await\n        .map(|(v, s)| match s.as_str() {\n            \"lts\" | \"default\" | \"system\" => format!(\"v{v}\"),\n            _ => format!(\"v{v} ({s})\"),\n        })\n        .unwrap_or(NOT_FOUND.to_string());\n\n    let env_rows = [(\"Package manager\", package_manager_info), (\"Node.js\", node_info)];\n\n    print_rows(\"Environment\", &env_rows);\n\n    Ok(ExitStatus::default())\n}\n\n#[cfg(test)]\nmod tests {\n    #[cfg(unix)]\n    use std::{fs, path::Path};\n\n    #[cfg(unix)]\n    use super::{ToolSpec, find_local_vite_plus, resolve_tool_version};\n    use super::{detect_system_node_version, format_version};\n\n    #[cfg(unix)]\n    fn symlink_dir(src: &Path, dst: &Path) {\n        std::os::unix::fs::symlink(src, dst).unwrap();\n    }\n\n    #[test]\n    fn format_version_values() {\n        assert_eq!(format_version(Some(\"1.2.3\".to_string())), \"v1.2.3\");\n        assert_eq!(format_version(None), \"Not found\");\n    }\n\n    #[test]\n    fn detect_system_node_version_returns_version() {\n        let version = detect_system_node_version();\n        assert!(version.is_some(), \"expected node to be installed\");\n        let version = version.unwrap();\n        assert!(!version.starts_with('v'), \"version should not have v prefix\");\n        assert!(version.contains('.'), \"expected semver-like version, got: {version}\");\n    }\n\n    #[cfg(unix)]\n    #[test]\n    fn resolves_tool_versions_from_pnpm_symlink_layout() {\n        let temp = tempfile::tempdir().unwrap();\n        let project = temp.path();\n\n        let pnpm_pkg_dir =\n            project.join(\"node_modules/.pnpm/vite-plus@1.0.0/node_modules/vite-plus\");\n        fs::create_dir_all(&pnpm_pkg_dir).unwrap();\n        fs::write(pnpm_pkg_dir.join(\"package.json\"), r#\"{\"version\":\"1.0.0\"}\"#).unwrap();\n\n        let core_pkg_dir = project\n            .join(\"node_modules/.pnpm/vite-plus@1.0.0/node_modules/@voidzero-dev/vite-plus-core\");\n        fs::create_dir_all(&core_pkg_dir).unwrap();\n        fs::write(\n            core_pkg_dir.join(\"package.json\"),\n            r#\"{\"version\":\"1.0.0\",\"bundledVersions\":{\"vite\":\"8.0.0\"}}\"#,\n        )\n        .unwrap();\n\n        let node_modules_dir = project.join(\"node_modules\");\n        fs::create_dir_all(&node_modules_dir).unwrap();\n        symlink_dir(\n            Path::new(\".pnpm/vite-plus@1.0.0/node_modules/vite-plus\"),\n            &node_modules_dir.join(\"vite-plus\"),\n        );\n\n        let local = find_local_vite_plus(project).expect(\"expected local vite-plus to resolve\");\n        let tool = ToolSpec {\n            display_name: \"vite\",\n            package_name: \"@voidzero-dev/vite-plus-core\",\n            bundled_version_key: Some(\"vite\"),\n        };\n        let resolved = resolve_tool_version(&local, tool);\n        assert_eq!(resolved.as_deref(), Some(\"8.0.0\"));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/vpx.rs",
    "content": "//! `vpx` command implementation.\n//!\n//! Executes a command from a local or remote npm package (like `npx`).\n//! Resolution order:\n//! 1. Local `node_modules/.bin` (walk up from cwd)\n//! 2. Global vp packages (installed via `vp install -g`)\n//! 3. System PATH (excluding vite-plus bin directory)\n//! 4. Remote download via `vp dlx`\n\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_shared::{PrependOptions, output, prepend_to_path_env};\n\nuse super::DlxCommand;\nuse crate::{commands::env::config, shim::dispatch};\n\n/// Parsed vpx flags.\n#[derive(Debug, Default)]\npub struct VpxFlags {\n    /// Packages to install (from --package/-p)\n    pub packages: Vec<String>,\n    /// Execute within a shell environment (-c/--shell-mode)\n    pub shell_mode: bool,\n    /// Suppress output (-s/--silent)\n    pub silent: bool,\n    /// Show help (-h/--help)\n    pub help: bool,\n}\n\n/// Help text for vpx.\nconst VPX_HELP: &str = \"\\\nExecute a command from a local or remote npm package\n\nUsage: vpx [OPTIONS] <pkg[@version]> [args...]\n\nArguments:\n  <pkg[@version]>  Package binary to execute\n  [args...]        Arguments to pass to the command\n\nOptions:\n  -p, --package <NAME>  Package(s) to install if not found locally\n  -c, --shell-mode      Execute the command within a shell environment\n  -s, --silent          Suppress all output except the command's output\n  -h, --help            Print help\n\nExamples:\n  vpx eslint .                                           # Run local eslint (or download)\n  vpx create-vue my-app                                  # Download and run create-vue\n  vpx typescript@5.5.4 tsc --version                     # Run specific version\n  vpx -p cowsay -c 'echo \\\"hi\\\" | cowsay'                  # Shell mode with package\";\n\n/// A globally installed binary found via `vp install -g`.\nstruct GlobalBinary {\n    path: AbsolutePathBuf,\n    is_js: bool,\n    node_version: String,\n}\n\n/// Main entry point for vpx execution.\n///\n/// Called from shim dispatch when `argv[0]` is `vpx`.\npub async fn execute_vpx(args: &[String], cwd: &AbsolutePath) -> i32 {\n    let (flags, positional) = parse_vpx_args(args);\n\n    // Show help\n    if flags.help {\n        println!(\"{VPX_HELP}\");\n        return 0;\n    }\n\n    // No command specified\n    if positional.is_empty() {\n        output::error(\"vpx requires a command to run\");\n        eprintln!();\n        eprintln!(\"Usage: vpx <pkg[@version]> [args...]\");\n        eprintln!();\n        eprintln!(\"Examples:\");\n        eprintln!(\"  vpx eslint .\");\n        eprintln!(\"  vpx create-vue my-app\");\n        return 1;\n    }\n\n    let cmd_spec = &positional[0];\n\n    // Extract the command name (binary to look for in node_modules/.bin)\n    let cmd_name = extract_command_name(cmd_spec);\n\n    // If no version spec and no --package flag, try local → global → PATH lookup\n    if !has_version_spec(cmd_spec) && flags.packages.is_empty() && !flags.shell_mode {\n        // 1. Try local node_modules/.bin\n        if let Some(local_bin) = find_local_binary(cwd, &cmd_name) {\n            tracing::debug!(\"vpx: found local binary at {}\", local_bin.as_path().display());\n            prepend_node_modules_bin_to_path(cwd);\n            let cmd_args: Vec<String> = positional[1..].to_vec();\n            return crate::shim::exec::exec_tool(&local_bin, &cmd_args);\n        }\n\n        // 2. Try global vp packages\n        if let Some(global_bin) = find_global_binary(&cmd_name).await {\n            tracing::debug!(\"vpx: found global binary at {}\", global_bin.path.as_path().display());\n            return execute_global_binary(global_bin, &positional[1..], cwd).await;\n        }\n\n        // 3. Try system PATH (excluding vite-plus bin dir)\n        if let Some(path_bin) = find_on_path(&cmd_name) {\n            tracing::debug!(\"vpx: found on PATH at {}\", path_bin.as_path().display());\n            prepend_node_modules_bin_to_path(cwd);\n            let cmd_args: Vec<String> = positional[1..].to_vec();\n            return crate::shim::exec::exec_tool(&path_bin, &cmd_args);\n        }\n    }\n\n    // 4. Fall back to dlx (remote download)\n    let cwd_buf = cwd.to_absolute_path_buf();\n    match DlxCommand::new(cwd_buf)\n        .execute(flags.packages, flags.shell_mode, flags.silent, positional)\n        .await\n    {\n        Ok(status) => status.code().unwrap_or(1),\n        Err(e) => {\n            output::error(&format!(\"vpx: {e}\"));\n            1\n        }\n    }\n}\n\n/// Find a binary in globally installed vp packages.\n///\n/// Uses the dispatch helpers to look up BinConfig and PackageMetadata.\nasync fn find_global_binary(cmd: &str) -> Option<GlobalBinary> {\n    let metadata = match dispatch::find_package_for_binary(cmd).await {\n        Ok(Some(m)) => m,\n        _ => return None,\n    };\n\n    let path = match dispatch::locate_package_binary(&metadata.name, cmd) {\n        Ok(p) => p,\n        Err(_) => return None,\n    };\n\n    Some(GlobalBinary {\n        is_js: metadata.is_js_binary(cmd),\n        node_version: metadata.platform.node.clone(),\n        path,\n    })\n}\n\n/// Execute a globally installed binary.\n///\n/// Ensures the required Node.js version is installed, prepends its bin dir\n/// and local node_modules/.bin dirs to PATH, then executes.\nasync fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &AbsolutePath) -> i32 {\n    // Ensure Node.js is installed\n    if let Err(e) = dispatch::ensure_installed(&bin.node_version).await {\n        output::error(&format!(\"vpx: Failed to install Node {}: {e}\", bin.node_version));\n        return 1;\n    }\n\n    // Locate node binary for this version\n    let node_path = match dispatch::locate_tool(&bin.node_version, \"node\") {\n        Ok(p) => p,\n        Err(e) => {\n            output::error(&format!(\"vpx: Node not found: {e}\"));\n            return 1;\n        }\n    };\n\n    // Prepend Node.js bin dir to PATH\n    let node_bin_dir = node_path.parent().expect(\"Node has no parent directory\");\n    prepend_to_path_env(node_bin_dir, PrependOptions::default());\n\n    // Prepend local node_modules/.bin dirs to PATH\n    prepend_node_modules_bin_to_path(cwd);\n\n    if bin.is_js {\n        // Execute: node <binary_path> <args>\n        let mut full_args = vec![bin.path.as_path().display().to_string()];\n        full_args.extend(args.iter().cloned());\n        crate::shim::exec::exec_tool(&node_path, &full_args)\n    } else {\n        crate::shim::exec::exec_tool(&bin.path, args)\n    }\n}\n\n/// Find a command on system PATH, excluding the vite-plus bin directory.\n///\n/// This prevents vpx from finding itself (or other vite-plus shims) on PATH.\nfn find_on_path(cmd: &str) -> Option<AbsolutePathBuf> {\n    let bin_dir = config::get_bin_dir().ok();\n    let path_var = std::env::var_os(\"PATH\")?;\n\n    // Filter PATH to exclude vite-plus bin directory\n    let filtered_paths: Vec<_> = std::env::split_paths(&path_var)\n        .filter(|p| {\n            if let Some(ref bin) = bin_dir {\n                if p == bin.as_path() {\n                    return false;\n                }\n            }\n            true\n        })\n        .collect();\n\n    let filtered_path = std::env::join_paths(filtered_paths).ok()?;\n    let cwd = vite_path::current_dir().ok()?;\n    vite_command::resolve_bin(cmd, Some(&filtered_path), &cwd).ok()\n}\n\n/// Prepend all `node_modules/.bin` directories from cwd upward to PATH.\n///\n/// Walks up from cwd and prepends each existing `node_modules/.bin` directory\n/// to PATH so that sub-processes also resolve local binaries first.\nfn prepend_node_modules_bin_to_path(cwd: &AbsolutePath) {\n    // Collect dirs bottom-up, then prepend in reverse so nearest is first\n    let mut bin_dirs = Vec::new();\n    let mut current = cwd;\n    loop {\n        let bin_dir = current.join(\"node_modules\").join(\".bin\");\n        if bin_dir.as_path().is_dir() {\n            bin_dirs.push(bin_dir);\n        }\n        match current.parent() {\n            Some(parent) if parent != current => current = parent,\n            _ => break,\n        }\n    }\n\n    // Prepend in reverse order so the nearest (deepest) directory ends up first\n    for dir in bin_dirs.iter().rev() {\n        prepend_to_path_env(dir, PrependOptions { dedupe_anywhere: true });\n    }\n}\n\n/// Walk up from `cwd` looking for `node_modules/.bin/<cmd>`.\n///\n/// On Windows, also checks for `.cmd` extension.\n/// Returns the absolute path to the binary if found.\npub fn find_local_binary(cwd: &AbsolutePath, cmd: &str) -> Option<AbsolutePathBuf> {\n    let mut current = cwd;\n    loop {\n        let bin_dir = current.join(\"node_modules\").join(\".bin\");\n        let bin_path = bin_dir.join(cmd);\n\n        if bin_path.as_path().exists() {\n            return Some(bin_path);\n        }\n\n        // On Windows, check for .cmd extension\n        #[cfg(windows)]\n        {\n            let cmd_path = bin_dir.join(format!(\"{cmd}.cmd\"));\n            if cmd_path.as_path().exists() {\n                return Some(cmd_path);\n            }\n        }\n\n        // Move to parent directory\n        match current.parent() {\n            Some(parent) if parent != current => current = parent,\n            _ => return None, // Reached filesystem root\n        }\n    }\n}\n\n/// Check if a package spec includes a version (e.g., `eslint@9`).\n///\n/// Scoped packages like `@vue/cli` are not version specs, but\n/// `@vue/cli@5.0.0` is.\npub fn has_version_spec(spec: &str) -> bool {\n    if spec.starts_with('@') {\n        // Scoped package: @scope/pkg@version\n        if let Some(slash_pos) = spec.find('/') {\n            return spec[slash_pos + 1..].contains('@');\n        }\n        // Just \"@scope\" with no slash — not a valid spec, no version\n        return false;\n    }\n    spec.contains('@')\n}\n\n/// Extract the command/binary name from a package spec.\n///\n/// Examples:\n/// - `eslint` → `eslint`\n/// - `eslint@9` → `eslint`\n/// - `@vue/cli` → `cli`\n/// - `@vue/cli@5.0.0` → `cli`\nfn extract_command_name(spec: &str) -> String {\n    if spec.starts_with('@') {\n        // Scoped package: @scope/pkg or @scope/pkg@version\n        if let Some(slash_pos) = spec.find('/') {\n            let after_slash = &spec[slash_pos + 1..];\n            // Strip version if present\n            if let Some(at_pos) = after_slash.find('@') {\n                return after_slash[..at_pos].to_string();\n            }\n            return after_slash.to_string();\n        }\n        // Just \"@scope\" — use as-is (unusual case)\n        return spec.to_string();\n    }\n    // Unscoped: pkg or pkg@version\n    if let Some(at_pos) = spec.find('@') { spec[..at_pos].to_string() } else { spec.to_string() }\n}\n\n/// Parse vpx flags from the argument slice.\n///\n/// All flags must come before the first positional argument (npx-style).\n/// Returns the parsed flags and remaining positional arguments.\npub fn parse_vpx_args(args: &[String]) -> (VpxFlags, Vec<String>) {\n    let mut flags = VpxFlags::default();\n    let mut positional = Vec::new();\n    let mut i = 0;\n\n    while i < args.len() {\n        let arg = &args[i];\n\n        // Once we see a non-flag argument, everything else is positional\n        if !arg.starts_with('-') {\n            positional.extend_from_slice(&args[i..]);\n            break;\n        }\n\n        match arg.as_str() {\n            \"-p\" | \"--package\" => {\n                i += 1;\n                if i < args.len() {\n                    flags.packages.push(args[i].clone());\n                }\n            }\n            \"-c\" | \"--shell-mode\" => {\n                flags.shell_mode = true;\n            }\n            \"-s\" | \"--silent\" => {\n                flags.silent = true;\n            }\n            \"-h\" | \"--help\" => {\n                flags.help = true;\n            }\n            other => {\n                // Handle --package=VALUE\n                if let Some(value) = other.strip_prefix(\"--package=\") {\n                    flags.packages.push(value.to_string());\n                } else if let Some(value) = other.strip_prefix(\"-p=\") {\n                    flags.packages.push(value.to_string());\n                } else {\n                    // Unknown flag — treat as start of positional args\n                    positional.extend_from_slice(&args[i..]);\n                    break;\n                }\n            }\n        }\n\n        i += 1;\n    }\n\n    (flags, positional)\n}\n\n#[cfg(test)]\nmod tests {\n    use serial_test::serial;\n\n    use super::*;\n\n    // =========================================================================\n    // has_version_spec tests\n    // =========================================================================\n\n    #[test]\n    fn test_has_version_spec_simple_package() {\n        assert!(!has_version_spec(\"eslint\"));\n    }\n\n    #[test]\n    fn test_has_version_spec_with_version() {\n        assert!(has_version_spec(\"eslint@9\"));\n    }\n\n    #[test]\n    fn test_has_version_spec_with_full_version() {\n        assert!(has_version_spec(\"typescript@5.5.4\"));\n    }\n\n    #[test]\n    fn test_has_version_spec_scoped_package_no_version() {\n        assert!(!has_version_spec(\"@vue/cli\"));\n    }\n\n    #[test]\n    fn test_has_version_spec_scoped_package_with_version() {\n        assert!(has_version_spec(\"@vue/cli@5.0.0\"));\n    }\n\n    #[test]\n    fn test_has_version_spec_scoped_no_slash() {\n        assert!(!has_version_spec(\"@vue\"));\n    }\n\n    #[test]\n    fn test_has_version_spec_with_tag() {\n        assert!(has_version_spec(\"eslint@latest\"));\n    }\n\n    // =========================================================================\n    // extract_command_name tests\n    // =========================================================================\n\n    #[test]\n    fn test_extract_command_name_simple() {\n        assert_eq!(extract_command_name(\"eslint\"), \"eslint\");\n    }\n\n    #[test]\n    fn test_extract_command_name_with_version() {\n        assert_eq!(extract_command_name(\"eslint@9\"), \"eslint\");\n    }\n\n    #[test]\n    fn test_extract_command_name_scoped() {\n        assert_eq!(extract_command_name(\"@vue/cli\"), \"cli\");\n    }\n\n    #[test]\n    fn test_extract_command_name_scoped_with_version() {\n        assert_eq!(extract_command_name(\"@vue/cli@5.0.0\"), \"cli\");\n    }\n\n    #[test]\n    fn test_extract_command_name_create_vue() {\n        assert_eq!(extract_command_name(\"create-vue\"), \"create-vue\");\n    }\n\n    // =========================================================================\n    // parse_vpx_args tests\n    // =========================================================================\n\n    #[test]\n    fn test_parse_vpx_args_simple_command() {\n        let args: Vec<String> = vec![\"eslint\".into(), \".\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert!(flags.packages.is_empty());\n        assert!(!flags.shell_mode);\n        assert!(!flags.silent);\n        assert!(!flags.help);\n        assert_eq!(positional, vec![\"eslint\", \".\"]);\n    }\n\n    #[test]\n    fn test_parse_vpx_args_with_package_flag() {\n        let args: Vec<String> =\n            vec![\"-p\".into(), \"cowsay\".into(), \"-c\".into(), \"echo hi | cowsay\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert_eq!(flags.packages, vec![\"cowsay\"]);\n        assert!(flags.shell_mode);\n        assert_eq!(positional, vec![\"echo hi | cowsay\"]);\n    }\n\n    #[test]\n    fn test_parse_vpx_args_with_long_package_flag() {\n        let args: Vec<String> = vec![\"--package\".into(), \"yo\".into(), \"yo\".into(), \"webapp\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert_eq!(flags.packages, vec![\"yo\"]);\n        assert_eq!(positional, vec![\"yo\", \"webapp\"]);\n    }\n\n    #[test]\n    fn test_parse_vpx_args_with_package_equals() {\n        let args: Vec<String> = vec![\"--package=cowsay\".into(), \"cowsay\".into(), \"hello\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert_eq!(flags.packages, vec![\"cowsay\"]);\n        assert_eq!(positional, vec![\"cowsay\", \"hello\"]);\n    }\n\n    #[test]\n    fn test_parse_vpx_args_multiple_packages() {\n        let args: Vec<String> = vec![\n            \"-p\".into(),\n            \"cowsay\".into(),\n            \"-p\".into(),\n            \"lolcatjs\".into(),\n            \"-c\".into(),\n            \"echo hi | cowsay | lolcatjs\".into(),\n        ];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert_eq!(flags.packages, vec![\"cowsay\", \"lolcatjs\"]);\n        assert!(flags.shell_mode);\n        assert_eq!(positional, vec![\"echo hi | cowsay | lolcatjs\"]);\n    }\n\n    #[test]\n    fn test_parse_vpx_args_silent() {\n        let args: Vec<String> = vec![\"-s\".into(), \"create-vue\".into(), \"my-app\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert!(flags.silent);\n        assert_eq!(positional, vec![\"create-vue\", \"my-app\"]);\n    }\n\n    #[test]\n    fn test_parse_vpx_args_help() {\n        let args: Vec<String> = vec![\"--help\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert!(flags.help);\n        assert!(positional.is_empty());\n    }\n\n    #[test]\n    fn test_parse_vpx_args_no_args() {\n        let args: Vec<String> = vec![];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert!(flags.packages.is_empty());\n        assert!(!flags.shell_mode);\n        assert!(!flags.silent);\n        assert!(!flags.help);\n        assert!(positional.is_empty());\n    }\n\n    #[test]\n    fn test_parse_vpx_args_unknown_flag_becomes_positional() {\n        let args: Vec<String> = vec![\"--version\".into()];\n        let (flags, positional) = parse_vpx_args(&args);\n        assert!(!flags.help);\n        assert_eq!(positional, vec![\"--version\"]);\n    }\n\n    // =========================================================================\n    // find_local_binary tests\n    // =========================================================================\n\n    #[test]\n    fn test_find_local_binary_in_cwd() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create node_modules/.bin/eslint\n        let bin_dir = temp_path.join(\"node_modules\").join(\".bin\");\n        std::fs::create_dir_all(&bin_dir).unwrap();\n        let eslint_path = bin_dir.join(\"eslint\");\n        std::fs::write(&eslint_path, \"#!/bin/sh\\n\").unwrap();\n\n        let result = find_local_binary(&temp_path, \"eslint\");\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().as_path(), eslint_path.as_path());\n    }\n\n    #[test]\n    fn test_find_local_binary_walks_up() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create node_modules/.bin/eslint at root\n        let bin_dir = temp_path.join(\"node_modules\").join(\".bin\");\n        std::fs::create_dir_all(&bin_dir).unwrap();\n        let eslint_path = bin_dir.join(\"eslint\");\n        std::fs::write(&eslint_path, \"#!/bin/sh\\n\").unwrap();\n\n        // Create nested directory\n        let nested_dir = temp_path.join(\"packages\").join(\"app\");\n        std::fs::create_dir_all(&nested_dir).unwrap();\n\n        let nested_abs = AbsolutePathBuf::new(nested_dir.as_path().to_path_buf()).unwrap();\n        let result = find_local_binary(&nested_abs, \"eslint\");\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().as_path(), eslint_path.as_path());\n    }\n\n    #[test]\n    fn test_find_local_binary_not_found() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        let result = find_local_binary(&temp_path, \"nonexistent-tool\");\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_find_local_binary_prefers_nearest() {\n        let temp_dir = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create eslint at root\n        let root_bin = temp_path.join(\"node_modules\").join(\".bin\");\n        std::fs::create_dir_all(&root_bin).unwrap();\n        std::fs::write(root_bin.join(\"eslint\"), \"root\").unwrap();\n\n        // Create eslint in nested package\n        let nested = temp_path.join(\"packages\").join(\"app\");\n        let nested_bin = nested.join(\"node_modules\").join(\".bin\");\n        std::fs::create_dir_all(&nested_bin).unwrap();\n        std::fs::write(nested_bin.join(\"eslint\"), \"nested\").unwrap();\n\n        let nested_abs = AbsolutePathBuf::new(nested.as_path().to_path_buf()).unwrap();\n        let result = find_local_binary(&nested_abs, \"eslint\");\n        assert!(result.is_some());\n        // Should find the nested one first\n        let found = result.unwrap();\n        assert_eq!(found.as_path(), nested_bin.join(\"eslint\").as_path());\n    }\n\n    // =========================================================================\n    // find_global_binary tests\n    // =========================================================================\n\n    #[tokio::test]\n    async fn test_find_global_binary_not_installed() {\n        // A binary that doesn't exist in any global package should return None\n        let result = find_global_binary(\"nonexistent-vpx-test-binary-xyz\").await;\n        assert!(result.is_none());\n    }\n\n    // =========================================================================\n    // find_on_path tests\n    // =========================================================================\n\n    #[cfg(unix)]\n    fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf {\n        use std::os::unix::fs::PermissionsExt;\n        let path = dir.join(name);\n        std::fs::write(&path, \"#!/bin/sh\\n\").unwrap();\n        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();\n        path\n    }\n\n    #[cfg(windows)]\n    fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf {\n        let path = dir.join(format!(\"{name}.exe\"));\n        std::fs::write(&path, \"fake\").unwrap();\n        path\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_on_path_finds_tool() {\n        let original_path = std::env::var_os(\"PATH\");\n        let temp = tempfile::tempdir().unwrap();\n        let dir = temp.path().join(\"bin_test\");\n        std::fs::create_dir_all(&dir).unwrap();\n        create_fake_executable(&dir, \"vpx-test-tool-abc\");\n\n        // SAFETY: serial test\n        unsafe {\n            std::env::set_var(\"PATH\", &dir);\n        }\n\n        let result = find_on_path(\"vpx-test-tool-abc\");\n        assert!(result.is_some());\n\n        unsafe {\n            match &original_path {\n                Some(v) => std::env::set_var(\"PATH\", v),\n                None => std::env::remove_var(\"PATH\"),\n            }\n        }\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_on_path_excludes_vp_bin_dir() {\n        let original_path = std::env::var_os(\"PATH\");\n        let original_home = std::env::var_os(\"VITE_PLUS_HOME\");\n        let temp = tempfile::tempdir().unwrap();\n\n        // Set up a fake vite-plus home with bin dir\n        let fake_home = temp.path().join(\"vite-plus-home\");\n        let fake_bin = fake_home.join(\"bin\");\n        std::fs::create_dir_all(&fake_bin).unwrap();\n        create_fake_executable(&fake_bin, \"vpx-excluded-tool\");\n\n        // Set up another directory with the same tool\n        let other_dir = temp.path().join(\"other_bin\");\n        std::fs::create_dir_all(&other_dir).unwrap();\n        create_fake_executable(&other_dir, \"vpx-excluded-tool\");\n\n        let path = std::env::join_paths([fake_bin.as_path(), other_dir.as_path()]).unwrap();\n\n        // SAFETY: serial test\n        unsafe {\n            std::env::set_var(\"PATH\", &path);\n            std::env::set_var(\"VITE_PLUS_HOME\", fake_home.as_os_str());\n        }\n\n        let result = find_on_path(\"vpx-excluded-tool\");\n        assert!(result.is_some());\n        // Should find the one in other_dir, not fake_bin\n        assert!(\n            result.unwrap().as_path().starts_with(&other_dir),\n            \"Should skip vite-plus bin dir and find tool in other directory\"\n        );\n\n        unsafe {\n            match &original_path {\n                Some(v) => std::env::set_var(\"PATH\", v),\n                None => std::env::remove_var(\"PATH\"),\n            }\n            match &original_home {\n                Some(v) => std::env::set_var(\"VITE_PLUS_HOME\", v),\n                None => std::env::remove_var(\"VITE_PLUS_HOME\"),\n            }\n        }\n    }\n\n    // =========================================================================\n    // prepend_node_modules_bin_to_path tests\n    // =========================================================================\n\n    #[test]\n    #[serial]\n    fn test_prepend_node_modules_bin_to_path() {\n        let original_path = std::env::var_os(\"PATH\");\n        let temp = tempfile::tempdir().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();\n\n        // Create node_modules/.bin at root\n        let root_bin = temp_path.join(\"node_modules\").join(\".bin\");\n        std::fs::create_dir_all(&root_bin).unwrap();\n\n        // Create node_modules/.bin in nested package\n        let nested = temp_path.join(\"packages\").join(\"app\");\n        let nested_bin = nested.join(\"node_modules\").join(\".bin\");\n        std::fs::create_dir_all(&nested_bin).unwrap();\n\n        // SAFETY: serial test\n        unsafe {\n            std::env::set_var(\"PATH\", \"/usr/bin\");\n        }\n\n        prepend_node_modules_bin_to_path(&nested);\n\n        let new_path = std::env::var_os(\"PATH\").unwrap();\n        let paths: Vec<_> = std::env::split_paths(&new_path).collect();\n\n        // Nearest (nested) should be first\n        assert_eq!(paths[0], nested_bin.as_path().to_path_buf());\n        // Root should be second\n        assert_eq!(paths[1], root_bin.as_path().to_path_buf());\n\n        unsafe {\n            match &original_path {\n                Some(v) => std::env::set_var(\"PATH\", v),\n                None => std::env::remove_var(\"PATH\"),\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/commands/why.rs",
    "content": "use std::process::ExitStatus;\n\nuse vite_install::commands::why::WhyCommandOptions;\nuse vite_path::AbsolutePathBuf;\n\nuse super::{build_package_manager, prepend_js_runtime_to_path_env};\nuse crate::error::Error;\n\n/// Why command for showing why a package is installed.\n///\n/// This command automatically detects the package manager and translates\n/// the why command to the appropriate package manager-specific syntax.\npub struct WhyCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl WhyCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    #[allow(clippy::too_many_arguments)]\n    pub async fn execute(\n        self,\n        packages: &[String],\n        json: bool,\n        long: bool,\n        parseable: bool,\n        recursive: bool,\n        filters: Option<&[String]>,\n        workspace_root: bool,\n        prod: bool,\n        dev: bool,\n        depth: Option<u32>,\n        no_optional: bool,\n        global: bool,\n        exclude_peers: bool,\n        find_by: Option<&str>,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExitStatus, Error> {\n        prepend_js_runtime_to_path_env(&self.cwd).await?;\n\n        let package_manager = build_package_manager(&self.cwd).await?;\n\n        let why_command_options = WhyCommandOptions {\n            packages,\n            json,\n            long,\n            parseable,\n            recursive,\n            filters,\n            workspace_root,\n            prod,\n            dev,\n            depth,\n            no_optional,\n            global,\n            exclude_peers,\n            find_by,\n            pass_through_args,\n        };\n        Ok(package_manager.run_why_command(&why_command_options, &self.cwd).await?)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_why_command_new() {\n        let workspace_root = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test\".into()).unwrap()\n        };\n\n        let cmd = WhyCommand::new(workspace_root.clone());\n        assert_eq!(cmd.cwd, workspace_root);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/error.rs",
    "content": "//! Error types for the global CLI.\n\nuse std::io;\n\nuse vite_str::Str;\n\n/// Error type for the global CLI.\n#[derive(Debug, thiserror::Error)]\npub enum Error {\n    #[allow(dead_code)] // Will be used for better error messages\n    #[error(\"No package manager detected. Please run in a project directory with a package.json.\")]\n    NoPackageManager,\n\n    #[error(\"Failed to download Node.js runtime: {0}\")]\n    RuntimeDownload(#[from] vite_js_runtime::Error),\n\n    #[error(\"Command execution failed: {0}\")]\n    CommandExecution(#[from] io::Error),\n\n    #[error(\n        \"JS scripts directory not found. Set VITE_GLOBAL_CLI_JS_SCRIPTS_DIR or ensure scripts are bundled.\"\n    )]\n    JsScriptsDirNotFound,\n\n    #[error(\"Failed to determine CLI binary path\")]\n    CliBinaryNotFound,\n\n    #[error(\"Workspace error: {0}\")]\n    Workspace(#[from] vite_workspace::Error),\n\n    #[error(\"Install error: {0}\")]\n    Install(#[from] vite_error::Error),\n\n    #[error(\"Configuration error: {0}\")]\n    ConfigError(Str),\n\n    #[error(\"JSON error: {0}\")]\n    JsonError(#[from] serde_json::Error),\n\n    #[error(\"{0}\")]\n    Other(Str),\n\n    /// User-facing message printed without \"Error: \" prefix.\n    #[error(\"{0}\")]\n    UserMessage(Str),\n\n    #[error(\n        \"Executable '{bin_name}' is already installed by {existing_package}\\n\\nPlease remove {existing_package} before installing {new_package}, or use --force to auto-replace\"\n    )]\n    BinaryConflict { bin_name: String, existing_package: String, new_package: String },\n\n    #[error(\"Upgrade error: {0}\")]\n    Upgrade(Str),\n\n    #[error(\"Integrity mismatch: expected {expected}, got {actual}\")]\n    IntegrityMismatch { expected: Str, actual: Str },\n\n    #[error(\"Unsupported integrity format: {0} (only sha512 is supported)\")]\n    UnsupportedIntegrity(Str),\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/help.rs",
    "content": "//! Unified help rendering for the global CLI.\n\nuse std::{fmt::Write as _, io::IsTerminal};\n\nuse clap::{CommandFactory, error::ErrorKind};\nuse owo_colors::OwoColorize;\n\n#[derive(Clone, Debug)]\npub struct HelpDoc {\n    pub usage: &'static str,\n    pub summary: Vec<&'static str>,\n    pub sections: Vec<HelpSection>,\n    pub documentation_url: Option<&'static str>,\n}\n\n#[derive(Clone, Debug)]\npub enum HelpSection {\n    Rows { title: &'static str, rows: Vec<HelpRow> },\n    Lines { title: &'static str, lines: Vec<&'static str> },\n}\n\n#[derive(Clone, Debug)]\npub struct HelpRow {\n    pub label: &'static str,\n    pub description: Vec<&'static str>,\n}\n\n#[derive(Clone, Debug)]\nstruct OwnedHelpDoc {\n    usage: String,\n    summary: Vec<String>,\n    sections: Vec<OwnedHelpSection>,\n    documentation_url: Option<String>,\n}\n\n#[derive(Clone, Debug)]\nenum OwnedHelpSection {\n    Rows { title: String, rows: Vec<OwnedHelpRow> },\n    Lines { title: String, lines: Vec<String> },\n}\n\n#[derive(Clone, Debug)]\nstruct OwnedHelpRow {\n    label: String,\n    description: Vec<String>,\n}\n\nfn row(label: &'static str, description: &'static str) -> HelpRow {\n    HelpRow { label, description: vec![description] }\n}\n\nfn section_rows(title: &'static str, rows: Vec<HelpRow>) -> HelpSection {\n    HelpSection::Rows { title, rows }\n}\n\nfn section_lines(title: &'static str, lines: Vec<&'static str>) -> HelpSection {\n    HelpSection::Lines { title, lines }\n}\n\nfn documentation_url_for_command_path(command_path: &[&str]) -> Option<&'static str> {\n    match command_path {\n        [] => Some(\"https://viteplus.dev/guide/\"),\n        [\"create\"] => Some(\"https://viteplus.dev/guide/create\"),\n        [\"migrate\"] => Some(\"https://viteplus.dev/guide/migrate\"),\n        [\"config\"] | [\"staged\"] => Some(\"https://viteplus.dev/guide/commit-hooks\"),\n        [\n            \"install\" | \"add\" | \"remove\" | \"update\" | \"dedupe\" | \"outdated\" | \"list\" | \"ls\" | \"why\"\n            | \"info\" | \"view\" | \"show\" | \"link\" | \"unlink\" | \"pm\",\n            ..,\n        ] => Some(\"https://viteplus.dev/guide/install\"),\n        [\"dev\"] => Some(\"https://viteplus.dev/guide/dev\"),\n        [\"check\"] => Some(\"https://viteplus.dev/guide/check\"),\n        [\"lint\"] => Some(\"https://viteplus.dev/guide/lint\"),\n        [\"fmt\"] => Some(\"https://viteplus.dev/guide/fmt\"),\n        [\"test\"] => Some(\"https://viteplus.dev/guide/test\"),\n        [\"run\"] => Some(\"https://viteplus.dev/guide/run\"),\n        [\"exec\" | \"dlx\"] => Some(\"https://viteplus.dev/guide/vpx\"),\n        [\"cache\"] => Some(\"https://viteplus.dev/guide/cache\"),\n        [\"build\" | \"preview\"] => Some(\"https://viteplus.dev/guide/build\"),\n        [\"pack\"] => Some(\"https://viteplus.dev/guide/pack\"),\n        [\"env\", ..] => Some(\"https://viteplus.dev/guide/env\"),\n        [\"upgrade\"] => Some(\"https://viteplus.dev/guide/upgrade\"),\n        _ => None,\n    }\n}\n\npub fn render_heading(title: &str) -> String {\n    let heading = format!(\"{title}:\");\n    if !should_style_help() {\n        return heading;\n    }\n\n    if should_accent_heading(title) {\n        heading.bold().bright_blue().to_string()\n    } else {\n        heading.bold().to_string()\n    }\n}\n\nfn render_usage_value(usage: &str) -> String {\n    if should_style_help() { usage.bold().to_string() } else { usage.to_string() }\n}\n\nfn should_accent_heading(title: &str) -> bool {\n    title != \"Usage\"\n}\n\nfn write_documentation_footer(output: &mut String, documentation_url: &str) {\n    let _ = writeln!(output);\n    let _ = writeln!(output, \"{} {documentation_url}\", render_heading(\"Documentation\"));\n}\n\npub fn should_style_help() -> bool {\n    std::io::stdout().is_terminal()\n        && std::env::var_os(\"NO_COLOR\").is_none()\n        && std::env::var(\"CLICOLOR\").map_or(true, |value| value != \"0\")\n        && std::env::var(\"TERM\").map_or(true, |term| term != \"dumb\")\n}\n\nfn render_rows(rows: &[HelpRow]) -> Vec<String> {\n    if rows.is_empty() {\n        return vec![];\n    }\n\n    let label_width = rows.iter().map(|row| row.label.len()).max().unwrap_or(0);\n    let mut output = Vec::new();\n\n    for row in rows {\n        let mut description_iter = row.description.iter();\n        if let Some(first) = description_iter.next() {\n            output.push(format!(\"  {:label_width$}  {}\", row.label, first));\n            for line in description_iter {\n                output.push(format!(\"  {:label_width$}  {}\", \"\", line));\n            }\n        } else {\n            output.push(format!(\"  {}\", row.label));\n        }\n    }\n\n    output\n}\n\nfn render_owned_rows(rows: &[OwnedHelpRow]) -> Vec<String> {\n    if rows.is_empty() {\n        return vec![];\n    }\n\n    let label_width = rows.iter().map(|row| row.label.chars().count()).max().unwrap_or(0);\n    let mut output = Vec::new();\n\n    for row in rows {\n        let mut description_iter = row.description.iter();\n        if let Some(first) = description_iter.next() {\n            output.push(format!(\"  {:label_width$}  {}\", row.label, first));\n            for line in description_iter {\n                output.push(format!(\"  {:label_width$}  {}\", \"\", line));\n            }\n        } else {\n            output.push(format!(\"  {}\", row.label));\n        }\n    }\n\n    output\n}\n\nfn split_comment_suffix(line: &str) -> Option<(&str, &str)> {\n    line.find(\" #\").map(|index| line.split_at(index))\n}\n\nfn render_muted_comment_suffix(line: &str) -> String {\n    if !should_style_help() {\n        return line.to_string();\n    }\n\n    if let Some((prefix, suffix)) = split_comment_suffix(line) {\n        return format!(\"{}{}\", prefix, suffix.bright_black());\n    }\n\n    line.to_string()\n}\n\npub fn render_help_doc(doc: &HelpDoc) -> String {\n    let mut output = String::new();\n\n    let _ = writeln!(output, \"{} {}\", render_heading(\"Usage\"), render_usage_value(doc.usage));\n\n    if !doc.summary.is_empty() {\n        let _ = writeln!(output);\n        for line in &doc.summary {\n            let _ = writeln!(output, \"{line}\");\n        }\n    }\n\n    for section in &doc.sections {\n        let _ = writeln!(output);\n        match section {\n            HelpSection::Rows { title, rows } => {\n                let _ = writeln!(output, \"{}\", render_heading(title));\n                for line in render_rows(rows) {\n                    let _ = writeln!(output, \"{line}\");\n                }\n            }\n            HelpSection::Lines { title, lines } => {\n                let _ = writeln!(output, \"{}\", render_heading(title));\n                for line in lines {\n                    let _ = writeln!(output, \"{}\", render_muted_comment_suffix(line));\n                }\n            }\n        }\n    }\n\n    if let Some(documentation_url) = doc.documentation_url {\n        write_documentation_footer(&mut output, documentation_url);\n    }\n\n    output\n}\n\nfn render_owned_help_doc(doc: &OwnedHelpDoc) -> String {\n    let mut output = String::new();\n\n    let _ = writeln!(output, \"{} {}\", render_heading(\"Usage\"), render_usage_value(&doc.usage));\n\n    if !doc.summary.is_empty() {\n        let _ = writeln!(output);\n        for line in &doc.summary {\n            let _ = writeln!(output, \"{line}\");\n        }\n    }\n\n    for section in &doc.sections {\n        let _ = writeln!(output);\n        match section {\n            OwnedHelpSection::Rows { title, rows } => {\n                let _ = writeln!(output, \"{}\", render_heading(title));\n                for line in render_owned_rows(rows) {\n                    let _ = writeln!(output, \"{line}\");\n                }\n            }\n            OwnedHelpSection::Lines { title, lines } => {\n                let _ = writeln!(output, \"{}\", render_heading(title));\n                for line in lines {\n                    let _ = writeln!(output, \"{}\", render_muted_comment_suffix(line));\n                }\n            }\n        }\n    }\n\n    if let Some(documentation_url) = &doc.documentation_url {\n        write_documentation_footer(&mut output, documentation_url);\n    }\n\n    output\n}\n\nfn is_section_heading(line: &str) -> bool {\n    let trimmed = line.trim_end();\n    !trimmed.is_empty() && !trimmed.starts_with(' ') && trimmed.ends_with(':')\n}\n\nfn split_label_and_description(content: &str) -> Option<(String, String)> {\n    let bytes = content.as_bytes();\n    let mut i = 0;\n\n    while i + 1 < bytes.len() {\n        if bytes[i] == b' ' && bytes[i + 1] == b' ' {\n            let mut j = i + 2;\n            while j < bytes.len() && bytes[j] == b' ' {\n                j += 1;\n            }\n\n            let label = content[..i].trim_end();\n            let description = content[j..].trim_start();\n            if !label.is_empty() && !description.is_empty() {\n                return Some((label.to_string(), description.to_string()));\n            }\n            i = j;\n            continue;\n        }\n        i += 1;\n    }\n\n    None\n}\n\nfn parse_rows(lines: &[String]) -> Vec<OwnedHelpRow> {\n    let mut rows = Vec::new();\n\n    for line in lines {\n        if line.trim().is_empty() {\n            continue;\n        }\n\n        let leading = line.chars().take_while(|c| *c == ' ').count();\n        let content = line.trim_start();\n        if content.is_empty() {\n            continue;\n        }\n\n        if let Some((label, description)) = split_label_and_description(content) {\n            rows.push(OwnedHelpRow { label, description: vec![description] });\n            continue;\n        }\n\n        if leading >= 4 && content.starts_with('-') {\n            rows.push(OwnedHelpRow { label: content.to_string(), description: vec![] });\n            continue;\n        }\n\n        if leading >= 4 {\n            if let Some(last) = rows.last_mut() {\n                last.description.push(content.to_string());\n                continue;\n            }\n        }\n\n        rows.push(OwnedHelpRow { label: content.to_string(), description: vec![] });\n    }\n\n    rows\n}\n\nfn strip_ansi(value: &str) -> String {\n    let mut output = String::with_capacity(value.len());\n    let mut chars = value.chars().peekable();\n\n    while let Some(ch) = chars.next() {\n        if ch == '\\u{1b}' {\n            match chars.peek().copied() {\n                // CSI sequence (for example: \\x1b[1m)\n                Some('[') => {\n                    let _ = chars.next();\n                    for c in chars.by_ref() {\n                        if ('@'..='~').contains(&c) {\n                            break;\n                        }\n                    }\n                }\n                // OSC sequence (for example: hyperlinks)\n                Some(']') => {\n                    let _ = chars.next();\n                    let mut prev = '\\0';\n                    for c in chars.by_ref() {\n                        if c == '\\u{7}' || (prev == '\\u{1b}' && c == '\\\\') {\n                            break;\n                        }\n                        prev = c;\n                    }\n                }\n                _ => {}\n            }\n            continue;\n        }\n\n        output.push(ch);\n    }\n\n    output\n}\n\nfn parse_clap_help_to_doc(raw_help: &str) -> Option<OwnedHelpDoc> {\n    let normalized = raw_help.replace(\"\\r\\n\", \"\\n\");\n    let lines: Vec<String> = normalized.lines().map(strip_ansi).collect();\n    let usage_index = lines.iter().position(|line| line.starts_with(\"Usage: \"))?;\n    let usage = lines[usage_index].trim_start_matches(\"Usage: \").trim().to_string();\n\n    let summary = lines[..usage_index]\n        .iter()\n        .map(|line| line.trim_end())\n        .filter(|line| !line.trim().is_empty())\n        .map(str::to_string)\n        .collect::<Vec<_>>();\n\n    let mut sections = Vec::new();\n    let mut i = usage_index + 1;\n    while i < lines.len() {\n        if lines[i].trim().is_empty() {\n            i += 1;\n            continue;\n        }\n\n        if !is_section_heading(&lines[i]) {\n            i += 1;\n            continue;\n        }\n\n        let title = lines[i].trim_end().trim_end_matches(':').to_string();\n        i += 1;\n\n        let mut body = Vec::new();\n        while i < lines.len() {\n            if is_section_heading(&lines[i]) {\n                break;\n            }\n            body.push(lines[i].trim_end().to_string());\n            i += 1;\n        }\n\n        let first_non_empty = body.iter().position(|line| !line.trim().is_empty());\n        let last_non_empty = body.iter().rposition(|line| !line.trim().is_empty());\n        let body = match (first_non_empty, last_non_empty) {\n            (Some(start), Some(end)) if start <= end => body[start..=end].to_vec(),\n            _ => vec![],\n        };\n\n        let row_sections =\n            matches!(title.as_str(), \"Arguments\" | \"Options\" | \"Commands\" | \"Subcommands\");\n        if row_sections {\n            let rows = parse_rows(&body);\n            sections.push(OwnedHelpSection::Rows { title, rows });\n        } else {\n            let lines = body.into_iter().filter(|line| !line.trim().is_empty()).collect::<Vec<_>>();\n            sections.push(OwnedHelpSection::Lines { title, lines });\n        }\n    }\n\n    Some(OwnedHelpDoc { usage, summary, sections, documentation_url: None })\n}\n\npub fn top_level_help_doc() -> HelpDoc {\n    HelpDoc {\n        usage: \"vp [COMMAND]\",\n        summary: Vec::new(),\n        sections: vec![\n            section_rows(\n                \"Start\",\n                vec![\n                    row(\"create\", \"Create a new project from a template\"),\n                    row(\"migrate\", \"Migrate an existing project to Vite+\"),\n                    row(\"config\", \"Configure hooks and agent integration\"),\n                    row(\"staged\", \"Run linters on staged files\"),\n                    row(\n                        \"install, i\",\n                        \"Install all dependencies, or add packages if package names are provided\",\n                    ),\n                    row(\"env\", \"Manage Node.js versions\"),\n                ],\n            ),\n            section_rows(\n                \"Develop\",\n                vec![\n                    row(\"dev\", \"Run the development server\"),\n                    row(\"check\", \"Run format, lint, and type checks\"),\n                    row(\"lint\", \"Lint code\"),\n                    row(\"fmt\", \"Format code\"),\n                    row(\"test\", \"Run tests\"),\n                ],\n            ),\n            section_rows(\n                \"Execute\",\n                vec![\n                    row(\"run\", \"Run tasks\"),\n                    row(\"exec\", \"Execute a command from local node_modules/.bin\"),\n                    row(\"dlx\", \"Execute a package binary without installing it as a dependency\"),\n                    row(\"cache\", \"Manage the task cache\"),\n                ],\n            ),\n            section_rows(\n                \"Build\",\n                vec![\n                    row(\"build\", \"Build for production\"),\n                    row(\"pack\", \"Build library\"),\n                    row(\"preview\", \"Preview production build\"),\n                ],\n            ),\n            section_rows(\n                \"Manage Dependencies\",\n                vec![\n                    row(\"add\", \"Add packages to dependencies\"),\n                    row(\"remove, rm, un, uninstall\", \"Remove packages from dependencies\"),\n                    row(\"update, up\", \"Update packages to their latest versions\"),\n                    row(\"dedupe\", \"Deduplicate dependencies by removing older versions\"),\n                    row(\"outdated\", \"Check for outdated packages\"),\n                    row(\"list, ls\", \"List installed packages\"),\n                    row(\"why, explain\", \"Show why a package is installed\"),\n                    row(\"info, view, show\", \"View package information from the registry\"),\n                    row(\"link, ln\", \"Link packages for local development\"),\n                    row(\"unlink\", \"Unlink packages\"),\n                    row(\"pm\", \"Forward a command to the package manager\"),\n                ],\n            ),\n            section_rows(\n                \"Maintain\",\n                vec![\n                    row(\"upgrade\", \"Update vp itself to the latest version\"),\n                    row(\"implode\", \"Remove vp and all related data\"),\n                ],\n            ),\n        ],\n        documentation_url: documentation_url_for_command_path(&[]),\n    }\n}\n\nfn env_help_doc() -> HelpDoc {\n    HelpDoc {\n        usage: \"vp env [COMMAND]\",\n        summary: vec![\"Manage Node.js versions\"],\n        sections: vec![\n            section_rows(\n                \"Setup\",\n                vec![\n                    row(\"setup\", \"Create or update shims in VITE_PLUS_HOME/bin\"),\n                    row(\"on\", \"Enable managed mode - shims always use vite-plus managed Node.js\"),\n                    row(\n                        \"off\",\n                        \"Enable system-first mode - shims prefer system Node.js, fallback to managed\",\n                    ),\n                    row(\"print\", \"Print shell snippet to set environment for current session\"),\n                ],\n            ),\n            section_rows(\n                \"Manage\",\n                vec![\n                    row(\"default\", \"Set or show the global default Node.js version\"),\n                    row(\n                        \"pin\",\n                        \"Pin a Node.js version in the current directory (creates .node-version)\",\n                    ),\n                    row(\n                        \"unpin\",\n                        \"Remove the .node-version file from current directory (alias for `pin --unpin`)\",\n                    ),\n                    row(\"use\", \"Use a specific Node.js version for this shell session\"),\n                    row(\"install\", \"Install a Node.js version [aliases: i]\"),\n                    row(\"uninstall\", \"Uninstall a Node.js version [aliases: uni]\"),\n                    row(\"exec\", \"Execute a command with a specific Node.js version [aliases: run]\"),\n                ],\n            ),\n            section_rows(\n                \"Inspect\",\n                vec![\n                    row(\"current\", \"Show current environment information\"),\n                    row(\"doctor\", \"Run diagnostics and show environment status\"),\n                    row(\"which\", \"Show path to the tool that would be executed\"),\n                    row(\"list\", \"List locally installed Node.js versions [aliases: ls]\"),\n                    row(\n                        \"list-remote\",\n                        \"List available Node.js versions from the registry [aliases: ls-remote]\",\n                    ),\n                ],\n            ),\n            section_lines(\n                \"Examples\",\n                vec![\n                    \"  Setup:\",\n                    \"    vp env setup                  # Create shims for node, npm, npx\",\n                    \"    vp env on                     # Use vite-plus managed Node.js\",\n                    \"    vp env print                  # Print shell snippet for this session\",\n                    \"\",\n                    \"  Manage:\",\n                    \"    vp env pin lts                # Pin to latest LTS version\",\n                    \"    vp env install                # Install version from .node-version / package.json\",\n                    \"    vp env use 20                 # Use Node.js 20 for this shell session\",\n                    \"    vp env use --unset            # Remove session override\",\n                    \"\",\n                    \"  Inspect:\",\n                    \"    vp env current                # Show current resolved environment\",\n                    \"    vp env current --json         # JSON output for automation\",\n                    \"    vp env doctor                 # Check environment configuration\",\n                    \"    vp env which node             # Show which node binary will be used\",\n                    \"    vp env list-remote --lts      # List only LTS versions\",\n                    \"\",\n                    \"  Execute:\",\n                    \"    vp env exec --node lts npm i  # Execute 'npm i' with latest LTS\",\n                    \"    vp env exec node -v           # Shim mode (version auto-resolved)\",\n                ],\n            ),\n            section_lines(\n                \"Related Commands\",\n                vec![\n                    \"  vp install -g <package>       # Install a package globally\",\n                    \"  vp uninstall -g <package>     # Uninstall a package globally\",\n                    \"  vp update -g [package]        # Update global packages\",\n                    \"  vp list -g [package]          # List global packages\",\n                ],\n            ),\n        ],\n        documentation_url: documentation_url_for_command_path(&[\"env\"]),\n    }\n}\n\nfn delegated_help_doc(command: &str) -> Option<HelpDoc> {\n    match command {\n        \"dev\" => Some(HelpDoc {\n            usage: \"vp dev [ROOT] [OPTIONS]\",\n            summary: vec![\"Run the development server.\", \"Options are forwarded to Vite.\"],\n            sections: vec![\n                section_rows(\n                    \"Arguments\",\n                    vec![row(\"[ROOT]\", \"Project root directory (default: current directory)\")],\n                ),\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"--host [HOST]\", \"Specify hostname\"),\n                        row(\"--port <PORT>\", \"Specify port\"),\n                        row(\"--open [PATH]\", \"Open browser on startup\"),\n                        row(\"--strictPort\", \"Exit if specified port is already in use\"),\n                        row(\"-c, --config <FILE>\", \"Use specified config file\"),\n                        row(\"--base <PATH>\", \"Public base path\"),\n                        row(\"-m, --mode <MODE>\", \"Set env mode\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\"  vp dev\", \"  vp dev --open\", \"  vp dev --host localhost --port 5173\"],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"dev\"]),\n        }),\n        \"build\" => Some(HelpDoc {\n            usage: \"vp build [ROOT] [OPTIONS]\",\n            summary: vec![\"Build for production.\", \"Options are forwarded to Vite.\"],\n            sections: vec![\n                section_rows(\n                    \"Arguments\",\n                    vec![row(\"[ROOT]\", \"Project root directory (default: current directory)\")],\n                ),\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"--target <TARGET>\", \"Transpile target\"),\n                        row(\"--outDir <DIR>\", \"Output directory\"),\n                        row(\"--sourcemap [MODE]\", \"Output source maps\"),\n                        row(\"--minify [MINIFIER]\", \"Enable/disable minification\"),\n                        row(\"-w, --watch\", \"Rebuild when files change\"),\n                        row(\"-c, --config <FILE>\", \"Use specified config file\"),\n                        row(\"-m, --mode <MODE>\", \"Set env mode\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\"  vp build\", \"  vp build --watch\", \"  vp build --sourcemap\"],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"build\"]),\n        }),\n        \"preview\" => Some(HelpDoc {\n            usage: \"vp preview [ROOT] [OPTIONS]\",\n            summary: vec![\"Preview production build.\", \"Options are forwarded to Vite.\"],\n            sections: vec![\n                section_rows(\n                    \"Arguments\",\n                    vec![row(\"[ROOT]\", \"Project root directory (default: current directory)\")],\n                ),\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"--host [HOST]\", \"Specify hostname\"),\n                        row(\"--port <PORT>\", \"Specify port\"),\n                        row(\"--strictPort\", \"Exit if specified port is already in use\"),\n                        row(\"--open [PATH]\", \"Open browser on startup\"),\n                        row(\"--outDir <DIR>\", \"Output directory to preview\"),\n                        row(\"-c, --config <FILE>\", \"Use specified config file\"),\n                        row(\"-m, --mode <MODE>\", \"Set env mode\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\"Examples\", vec![\"  vp preview\", \"  vp preview --port 4173\"]),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"preview\"]),\n        }),\n        \"test\" => Some(HelpDoc {\n            usage: \"vp test [COMMAND] [FILTERS] [OPTIONS]\",\n            summary: vec![\"Run tests.\", \"Options are forwarded to Vitest.\"],\n            sections: vec![\n                section_rows(\n                    \"Commands\",\n                    vec![\n                        row(\"run\", \"Run tests once\"),\n                        row(\"watch\", \"Run tests in watch mode\"),\n                        row(\"dev\", \"Run tests in development mode\"),\n                        row(\"related\", \"Run tests related to changed files\"),\n                        row(\"bench\", \"Run benchmarks\"),\n                        row(\"init\", \"Initialize Vitest config\"),\n                        row(\"list\", \"List matching tests\"),\n                    ],\n                ),\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"-c, --config <PATH>\", \"Path to config file\"),\n                        row(\"-w, --watch\", \"Enable watch mode\"),\n                        row(\"-t, --testNamePattern <PATTERN>\", \"Run tests matching regexp\"),\n                        row(\"--ui\", \"Enable UI\"),\n                        row(\"--coverage\", \"Enable coverage\"),\n                        row(\"--reporter <NAME>\", \"Specify reporter\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\n                        \"  vp test\",\n                        \"  vp test run src/foo.test.ts\",\n                        \"  vp test watch --coverage\",\n                    ],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"test\"]),\n        }),\n        \"lint\" => Some(HelpDoc {\n            usage: \"vp lint [PATH]... [OPTIONS]\",\n            summary: vec![\"Lint code.\", \"Options are forwarded to Oxlint.\"],\n            sections: vec![\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"--tsconfig <PATH>\", \"TypeScript tsconfig path\"),\n                        row(\"--fix\", \"Fix issues when possible\"),\n                        row(\"--type-aware\", \"Enable rules requiring type information\"),\n                        row(\"--import-plugin\", \"Enable import plugin\"),\n                        row(\"--rules\", \"List registered rules\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\n                        \"  vp lint\",\n                        \"  vp lint src --fix\",\n                        \"  vp lint --type-aware --tsconfig ./tsconfig.json\",\n                    ],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"lint\"]),\n        }),\n        \"fmt\" => Some(HelpDoc {\n            usage: \"vp fmt [PATH]... [OPTIONS]\",\n            summary: vec![\"Format code.\", \"Options are forwarded to Oxfmt.\"],\n            sections: vec![\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"--write\", \"Format and write files in place\"),\n                        row(\"--check\", \"Check if files are formatted\"),\n                        row(\"--list-different\", \"List files that would be changed\"),\n                        row(\"--ignore-path <PATH>\", \"Path to ignore file(s)\"),\n                        row(\"--threads <INT>\", \"Number of threads to use\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\"  vp fmt\", \"  vp fmt src --check\", \"  vp fmt . --write\"],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"fmt\"]),\n        }),\n        \"check\" => Some(HelpDoc {\n            usage: \"vp check [OPTIONS] [PATHS]...\",\n            summary: vec![\"Run format, lint, and type checks.\"],\n            sections: vec![\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"--fix\", \"Auto-fix format and lint issues\"),\n                        row(\"--no-fmt\", \"Skip format check\"),\n                        row(\"--no-lint\", \"Skip lint check\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\"  vp check\", \"  vp check --fix\", \"  vp check --no-lint src/index.ts\"],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"check\"]),\n        }),\n        \"pack\" => Some(HelpDoc {\n            usage: \"vp pack [...FILES] [OPTIONS]\",\n            summary: vec![\"Build library.\", \"Options are forwarded to tsdown.\"],\n            sections: vec![\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"-f, --format <FORMAT>\", \"Bundle format: esm, cjs, iife, umd\"),\n                        row(\"-d, --out-dir <DIR>\", \"Output directory\"),\n                        row(\"--sourcemap\", \"Generate source map\"),\n                        row(\"--dts\", \"Generate dts files\"),\n                        row(\"--minify\", \"Minify output\"),\n                        row(\"-w, --watch [PATH]\", \"Watch mode\"),\n                        row(\"-h, --help\", \"Print help\"),\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\"  vp pack\", \"  vp pack src/index.ts --dts\", \"  vp pack --watch\"],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"pack\"]),\n        }),\n        \"run\" => Some(HelpDoc {\n            usage: \"vp run [OPTIONS] [TASK_SPECIFIER] [ADDITIONAL_ARGS]...\",\n            summary: vec![\"Run tasks.\"],\n            sections: vec![\n                section_rows(\n                    \"Arguments\",\n                    vec![\n                        row(\n                            \"[TASK_SPECIFIER]\",\n                            \"`packageName#taskName` or `taskName`. If omitted, lists all available tasks\",\n                        ),\n                        row(\"[ADDITIONAL_ARGS]...\", \"Additional arguments to pass to the tasks\"),\n                    ],\n                ),\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"-r, --recursive\", \"Select all packages in the workspace\"),\n                        row(\n                            \"-t, --transitive\",\n                            \"Select the current package and its transitive dependencies\",\n                        ),\n                        row(\"-w, --workspace-root\", \"Select the workspace root package\"),\n                        row(\n                            \"-F, --filter <FILTERS>\",\n                            \"Match packages by name, directory, or glob pattern\",\n                        ),\n                        row(\n                            \"--ignore-depends-on\",\n                            \"Do not run dependencies specified in `dependsOn` fields\",\n                        ),\n                        row(\"-v, --verbose\", \"Show full detailed summary after execution\"),\n                        row(\"--last-details\", \"Display the detailed summary of the last run\"),\n                        row(\"-h, --help\", \"Print help (see more with '--help')\"),\n                    ],\n                ),\n                section_lines(\n                    \"Filter Patterns\",\n                    vec![\n                        \"  --filter <pattern>        Select by package name (e.g. foo, @scope/*)\",\n                        \"  --filter ./<dir>          Select packages under a directory\",\n                        \"  --filter {<dir>}          Same as ./<dir>, but allows traversal suffixes\",\n                        \"  --filter <pattern>...     Select package and its dependencies\",\n                        \"  --filter ...<pattern>     Select package and its dependents\",\n                        \"  --filter <pattern>^...    Select only the dependencies (exclude the package itself)\",\n                        \"  --filter !<pattern>       Exclude packages matching the pattern\",\n                    ],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"run\"]),\n        }),\n        \"exec\" => Some(HelpDoc {\n            usage: \"vp exec [OPTIONS] [COMMAND]...\",\n            summary: vec![\"Execute a command from local node_modules/.bin.\"],\n            sections: vec![\n                section_rows(\n                    \"Arguments\",\n                    vec![row(\"[COMMAND]...\", \"Command and arguments to execute\")],\n                ),\n                section_rows(\n                    \"Options\",\n                    vec![\n                        row(\"-r, --recursive\", \"Select all packages in the workspace\"),\n                        row(\n                            \"-t, --transitive\",\n                            \"Select the current package and its transitive dependencies\",\n                        ),\n                        row(\"-w, --workspace-root\", \"Select the workspace root package\"),\n                        row(\n                            \"-F, --filter <FILTERS>\",\n                            \"Match packages by name, directory, or glob pattern\",\n                        ),\n                        row(\"-c, --shell-mode\", \"Execute the command within a shell environment\"),\n                        row(\"--parallel\", \"Run concurrently without topological ordering\"),\n                        row(\"--reverse\", \"Reverse execution order\"),\n                        row(\"--resume-from <RESUME_FROM>\", \"Resume from a specific package\"),\n                        row(\"--report-summary\", \"Save results to vp-exec-summary.json\"),\n                        row(\"-h, --help\", \"Print help (see more with '--help')\"),\n                    ],\n                ),\n                section_lines(\n                    \"Filter Patterns\",\n                    vec![\n                        \"  --filter <pattern>        Select by package name (e.g. foo, @scope/*)\",\n                        \"  --filter ./<dir>          Select packages under a directory\",\n                        \"  --filter {<dir>}          Same as ./<dir>, but allows traversal suffixes\",\n                        \"  --filter <pattern>...     Select package and its dependencies\",\n                        \"  --filter ...<pattern>     Select package and its dependents\",\n                        \"  --filter <pattern>^...    Select only the dependencies (exclude the package itself)\",\n                        \"  --filter !<pattern>       Exclude packages matching the pattern\",\n                    ],\n                ),\n                section_lines(\n                    \"Examples\",\n                    vec![\n                        \"  vp exec node --version                             # Run local node\",\n                        \"  vp exec tsc --noEmit                               # Run local TypeScript compiler\",\n                        \"  vp exec -c 'tsc --noEmit && prettier --check .'    # Shell mode\",\n                        \"  vp exec -r -- tsc --noEmit                         # Run in all workspace packages\",\n                        \"  vp exec --filter 'app...' -- tsc                   # Run in filtered packages\",\n                    ],\n                ),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"exec\"]),\n        }),\n        \"cache\" => Some(HelpDoc {\n            usage: \"vp cache <COMMAND>\",\n            summary: vec![\"Manage the task cache.\"],\n            sections: vec![\n                section_rows(\"Commands\", vec![row(\"clean\", \"Clean up all the cache\")]),\n                section_rows(\"Options\", vec![row(\"-h, --help\", \"Print help\")]),\n            ],\n            documentation_url: documentation_url_for_command_path(&[\"cache\"]),\n        }),\n        _ => None,\n    }\n}\n\nfn is_help_flag(arg: &str) -> bool {\n    matches!(arg, \"-h\" | \"--help\")\n}\n\nfn has_help_flag_before_terminator(args: &[String]) -> bool {\n    args.iter().take_while(|arg| arg.as_str() != \"--\").any(|arg| is_help_flag(arg))\n}\n\nfn skip_clap_unified_help(command: &str) -> bool {\n    matches!(\n        command,\n        \"create\"\n            | \"migrate\"\n            | \"dev\"\n            | \"build\"\n            | \"preview\"\n            | \"test\"\n            | \"lint\"\n            | \"fmt\"\n            | \"check\"\n            | \"pack\"\n            | \"run\"\n            | \"exec\"\n            | \"cache\"\n    )\n}\n\npub fn maybe_print_unified_clap_subcommand_help(argv: &[String]) -> bool {\n    if argv.len() < 3 {\n        return false;\n    }\n\n    let command = crate::cli::Args::command();\n    let mut current = &command;\n    let mut path_len = 0;\n    let mut index = 1;\n    let mut first_command_name: Option<String> = None;\n    let mut command_path = Vec::new();\n\n    while index < argv.len() {\n        let arg = &argv[index];\n        if arg.starts_with('-') {\n            break;\n        }\n\n        let Some(next) = current.find_subcommand(arg) else {\n            break;\n        };\n\n        if first_command_name.is_none() {\n            first_command_name = Some(next.get_name().to_string());\n        }\n\n        command_path.push(next.get_name().to_string());\n        current = next;\n        path_len += 1;\n        index += 1;\n    }\n\n    if path_len == 0 {\n        return false;\n    }\n\n    let Some(first_command_name) = first_command_name else {\n        return false;\n    };\n    if skip_clap_unified_help(&first_command_name) {\n        return false;\n    }\n\n    // Respect `--` option terminator: flags after `--` belong to the wrapped\n    // command and should not trigger CLI help rewriting.\n    if !has_help_flag_before_terminator(&argv[index..]) {\n        return false;\n    }\n\n    if command_path.len() == 1 && command_path[0] == \"env\" {\n        println!(\"{}\", vite_shared::header::vite_plus_header());\n        println!();\n        println!(\"{}\", render_help_doc(&env_help_doc()));\n        return true;\n    }\n\n    let mut command_path_refs = Vec::with_capacity(command_path.len());\n    for segment in &command_path {\n        command_path_refs.push(segment.as_str());\n    }\n    print_unified_clap_help_for_path(&command_path_refs)\n}\n\npub fn should_print_unified_delegate_help(args: &[String]) -> bool {\n    matches!(args, [arg] if is_help_flag(arg))\n}\n\npub fn maybe_print_unified_delegate_help(\n    command: &str,\n    args: &[String],\n    show_header: bool,\n) -> bool {\n    if !should_print_unified_delegate_help(args) {\n        return false;\n    }\n\n    let Some(doc) = delegated_help_doc(command) else {\n        return false;\n    };\n\n    if show_header {\n        println!(\"{}\", vite_shared::header::vite_plus_header());\n        println!();\n    }\n    println!(\"{}\", render_help_doc(&doc));\n    true\n}\n\npub fn print_unified_clap_help_for_path(command_path: &[&str]) -> bool {\n    if command_path == [\"env\"] {\n        println!(\"{}\", vite_shared::header::vite_plus_header());\n        println!();\n        println!(\"{}\", render_help_doc(&env_help_doc()));\n        return true;\n    }\n\n    let mut help_args = vec![\"vp\".to_string()];\n    help_args.extend(command_path.iter().map(ToString::to_string));\n    help_args.push(\"--help\".to_string());\n\n    let raw_help = match crate::cli::try_parse_args_from(help_args) {\n        Err(error) if matches!(error.kind(), ErrorKind::DisplayHelp) => error.to_string(),\n        _ => return false,\n    };\n\n    let Some(doc) = parse_clap_help_to_doc(&raw_help) else {\n        return false;\n    };\n    let doc = OwnedHelpDoc {\n        documentation_url: documentation_url_for_command_path(command_path)\n            .map(ToString::to_string),\n        ..doc\n    };\n\n    println!(\"{}\", vite_shared::header::vite_plus_header());\n    println!();\n    println!(\"{}\", render_owned_help_doc(&doc));\n    true\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{\n        HelpDoc, documentation_url_for_command_path, has_help_flag_before_terminator,\n        parse_clap_help_to_doc, parse_rows, render_help_doc, split_comment_suffix, strip_ansi,\n    };\n\n    #[test]\n    fn parse_rows_supports_wrapped_option_labels() {\n        let lines = vec![\n            \"  -P, --prod            Do not install devDependencies\".to_string(),\n            \"  --no-optional\".to_string(),\n            \"                        Do not install optionalDependencies\".to_string(),\n        ];\n\n        let rows = parse_rows(&lines);\n        assert_eq!(rows.len(), 2);\n        assert_eq!(rows[0].label, \"-P, --prod\");\n        assert_eq!(rows[0].description, vec![\"Do not install devDependencies\"]);\n        assert_eq!(rows[1].label, \"--no-optional\");\n        assert_eq!(rows[1].description, vec![\"Do not install optionalDependencies\"]);\n    }\n\n    #[test]\n    fn parse_clap_help_extracts_usage_summary_and_sections() {\n        let raw_help = \"\\\nAdd packages to dependencies\n\nUsage: vp add [OPTIONS] <PACKAGES>...\n\nArguments:\n  <PACKAGES>...  Packages to add\n\nOptions:\n  -h, --help  Print help\n\";\n\n        let doc = parse_clap_help_to_doc(raw_help).expect(\"should parse clap help text\");\n        assert_eq!(doc.usage, \"vp add [OPTIONS] <PACKAGES>...\");\n        assert_eq!(doc.summary, vec![\"Add packages to dependencies\"]);\n        assert_eq!(doc.sections.len(), 2);\n    }\n\n    #[test]\n    fn help_flag_before_terminator_is_detected() {\n        let args = vec![\"vpx\".to_string(), \"--help\".to_string()];\n        assert!(has_help_flag_before_terminator(&args));\n    }\n\n    #[test]\n    fn help_flag_after_terminator_is_ignored() {\n        let args = vec![\"vpx\".to_string(), \"--\".to_string(), \"--help\".to_string()];\n        assert!(!has_help_flag_before_terminator(&args));\n    }\n\n    #[test]\n    fn strip_ansi_removes_csi_sequences() {\n        let input = \"\\u{1b}[1mOptions:\\u{1b}[0m\";\n        assert_eq!(strip_ansi(input), \"Options:\");\n    }\n\n    #[test]\n    fn parse_clap_help_with_ansi_sequences() {\n        let raw_help = \"\\\n\\u{1b}[1mAdd packages to dependencies\\u{1b}[0m\n\n\\u{1b}[1mUsage:\\u{1b}[0m vp add [OPTIONS] <PACKAGES>...\n\n\\u{1b}[1mArguments:\\u{1b}[0m\n  <PACKAGES>...  Packages to add\n\n\\u{1b}[1mOptions:\\u{1b}[0m\n  -h, --help  Print help\n\";\n\n        let doc = parse_clap_help_to_doc(raw_help).expect(\"should parse clap help text\");\n        assert_eq!(doc.usage, \"vp add [OPTIONS] <PACKAGES>...\");\n        assert_eq!(doc.summary, vec![\"Add packages to dependencies\"]);\n        assert_eq!(doc.sections.len(), 2);\n    }\n\n    #[test]\n    fn split_comment_suffix_extracts_command_comment() {\n        let line = \"  vp env list-remote 20         # List Node.js 20.x versions\";\n        let (prefix, suffix) = split_comment_suffix(line).expect(\"expected comment suffix\");\n        assert_eq!(prefix, \"  vp env list-remote 20        \");\n        assert_eq!(suffix, \" # List Node.js 20.x versions\");\n    }\n\n    #[test]\n    fn split_comment_suffix_returns_none_without_comment() {\n        assert!(split_comment_suffix(\"  vp env list\").is_none());\n    }\n\n    #[test]\n    fn docs_url_is_mapped_for_grouped_commands() {\n        assert_eq!(\n            documentation_url_for_command_path(&[\"add\"]),\n            Some(\"https://viteplus.dev/guide/install\")\n        );\n        assert_eq!(\n            documentation_url_for_command_path(&[\"env\", \"list\"]),\n            Some(\"https://viteplus.dev/guide/env\")\n        );\n        assert_eq!(\n            documentation_url_for_command_path(&[\"config\"]),\n            Some(\"https://viteplus.dev/guide/commit-hooks\")\n        );\n    }\n\n    #[test]\n    fn render_help_doc_appends_documentation_footer() {\n        let output = render_help_doc(&HelpDoc {\n            usage: \"vp demo\",\n            summary: vec![],\n            sections: vec![],\n            documentation_url: Some(\"https://viteplus.dev/guide/demo\"),\n        });\n\n        assert!(output.contains(\"Documentation: https://viteplus.dev/guide/demo\"));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/js_executor.rs",
    "content": "//! JavaScript execution via managed Node.js runtime.\n//!\n//! This module handles downloading and caching Node.js via `vite_js_runtime`,\n//! and executing JavaScript scripts using the managed runtime.\n\nuse std::process::ExitStatus;\n\nuse tokio::process::Command;\nuse vite_js_runtime::{\n    JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, is_valid_version,\n    read_package_json, resolve_node_version,\n};\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend};\n\nuse crate::{commands::env::config, error::Error};\n\n/// JavaScript executor using managed Node.js runtime.\n///\n/// Handles two runtime resolution strategies:\n/// - CLI runtime: For package manager commands and bundled JS scripts (Categories A & B)\n/// - Project runtime: For delegating to local vite-plus CLI (Category C)\npub struct JsExecutor {\n    /// Cached runtime for CLI commands (Categories A & B)\n    cli_runtime: Option<JsRuntime>,\n    /// Cached runtime for project delegation (Category C)\n    project_runtime: Option<JsRuntime>,\n    /// Directory containing JS scripts (from `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR`)\n    scripts_dir: Option<AbsolutePathBuf>,\n}\n\nimpl JsExecutor {\n    /// Create a new JS executor.\n    ///\n    /// # Arguments\n    /// * `scripts_dir` - Optional path to the JS scripts directory.\n    ///   If not provided, will be auto-detected from the binary location.\n    #[must_use]\n    pub const fn new(scripts_dir: Option<AbsolutePathBuf>) -> Self {\n        Self { cli_runtime: None, project_runtime: None, scripts_dir }\n    }\n\n    /// Get the JS scripts directory.\n    ///\n    /// Resolution order:\n    /// 1. Explicitly provided `scripts_dir`\n    /// 2. `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR` environment variable\n    /// 3. Auto-detect from binary location (../dist relative to binary)\n    pub fn get_scripts_dir(&self) -> Result<AbsolutePathBuf, Error> {\n        // 1. Use explicitly provided scripts_dir\n        if let Some(dir) = &self.scripts_dir {\n            return Ok(dir.clone());\n        }\n\n        // 2. Check environment variable\n        if let Ok(dir) = std::env::var(env_vars::VITE_GLOBAL_CLI_JS_SCRIPTS_DIR) {\n            return AbsolutePathBuf::new(dir.into()).ok_or(Error::JsScriptsDirNotFound);\n        }\n\n        // 3. Auto-detect from binary location\n        // JS scripts are at ../node_modules/vite-plus/dist relative to the binary directory\n        // e.g., ~/.vite-plus/<version>/bin/vp -> ~/.vite-plus/<version>/node_modules/vite-plus/dist/\n        let exe_path = std::env::current_exe().map_err(|_| Error::JsScriptsDirNotFound)?;\n        // Resolve symlinks to get the real binary path (Unix only)\n        // Skip on Windows to avoid path resolution issues\n        #[cfg(unix)]\n        let exe_path = std::fs::canonicalize(&exe_path).map_err(|_| Error::JsScriptsDirNotFound)?;\n        let bin_dir = exe_path.parent().ok_or(Error::JsScriptsDirNotFound)?;\n        let version_dir = bin_dir.parent().ok_or(Error::JsScriptsDirNotFound)?;\n        let scripts_dir = version_dir.join(\"node_modules\").join(\"vite-plus\").join(\"dist\");\n\n        AbsolutePathBuf::new(scripts_dir).ok_or(Error::JsScriptsDirNotFound)\n    }\n\n    /// Get the path to the current Rust binary (vp).\n    ///\n    /// This is passed to JS scripts via `VITE_PLUS_CLI_BIN` environment variable\n    /// so they can invoke vp commands when needed.\n    fn get_bin_path() -> Result<AbsolutePathBuf, Error> {\n        let exe_path = std::env::current_exe().map_err(|_| Error::CliBinaryNotFound)?;\n        AbsolutePathBuf::new(exe_path).ok_or(Error::CliBinaryNotFound)\n    }\n\n    /// Create a JS runtime command with common environment variables set.\n    ///\n    /// Sets up:\n    /// - `VITE_PLUS_CLI_BIN`: So JS scripts can invoke vp commands\n    /// - `PATH`: Prepends the runtime bin directory so child processes can find the JS runtime\n    fn create_js_command(\n        runtime_binary: &AbsolutePath,\n        runtime_bin_prefix: &AbsolutePath,\n    ) -> Command {\n        let mut cmd = Command::new(runtime_binary.as_path());\n        if let Ok(bin_path) = Self::get_bin_path() {\n            tracing::debug!(\"Set VITE_PLUS_CLI_BIN to {:?}\", bin_path);\n            cmd.env(env_vars::VITE_PLUS_CLI_BIN, bin_path.as_path());\n        }\n\n        // Prepend runtime bin to PATH so child processes can find the JS runtime\n        let options = PrependOptions { dedupe_anywhere: true };\n        if let PrependResult::Prepended(new_path) =\n            format_path_with_prepend(runtime_bin_prefix.as_path(), options)\n        {\n            tracing::debug!(\"Set PATH to {:?}\", new_path);\n            cmd.env(\"PATH\", new_path);\n        }\n\n        cmd\n    }\n\n    /// Get the CLI's package.json directory (parent of `scripts_dir`).\n    ///\n    /// This is used for resolving the CLI's default Node.js version\n    /// from `devEngines.runtime` in the CLI's package.json.\n    fn get_cli_package_dir(&self) -> Result<AbsolutePathBuf, Error> {\n        let scripts_dir = self.get_scripts_dir()?;\n        // scripts_dir is typically packages/cli/dist, so parent is packages/cli\n        scripts_dir\n            .parent()\n            .map(vite_path::AbsolutePath::to_absolute_path_buf)\n            .ok_or(Error::JsScriptsDirNotFound)\n    }\n\n    /// Ensure the CLI runtime is downloaded and cached.\n    ///\n    /// Uses the CLI's package.json `devEngines.runtime` configuration\n    /// to determine which Node.js version to use.\n    pub async fn ensure_cli_runtime(&mut self) -> Result<&JsRuntime, Error> {\n        if self.cli_runtime.is_none() {\n            let cli_dir = self.get_cli_package_dir()?;\n            tracing::debug!(\"Resolving CLI runtime from {:?}\", cli_dir);\n            let runtime = download_runtime_for_project(&cli_dir).await?;\n            self.cli_runtime = Some(runtime);\n        }\n        Ok(self.cli_runtime.as_ref().unwrap())\n    }\n\n    /// Ensure the project runtime is downloaded and cached.\n    ///\n    /// Resolution order:\n    /// 1. Session override (env var from `vp env use`)\n    /// 2. Session override (file from `vp env use`)\n    /// 3. Project sources (.node-version, engines.node, devEngines.runtime) —\n    ///    delegates to `download_runtime_for_project()` for cache-aware resolution\n    /// 4. User default from config.json\n    /// 5. Latest LTS\n    pub async fn ensure_project_runtime(\n        &mut self,\n        project_path: &AbsolutePath,\n    ) -> Result<&JsRuntime, Error> {\n        if self.project_runtime.is_none() {\n            tracing::debug!(\"Resolving project runtime from {:?}\", project_path);\n\n            // 1–2. Session overrides: env var (from `vp env use`), then file\n            let session_version = vite_shared::EnvConfig::get()\n                .node_version\n                .map(|v| v.trim().to_string())\n                .filter(|v| !v.is_empty());\n            let session_version = if session_version.is_some() {\n                session_version\n            } else {\n                config::read_session_version().await\n            };\n            if let Some(version) = session_version {\n                let runtime = download_runtime(JsRuntimeType::Node, &version).await?;\n                return Ok(self.project_runtime.insert(runtime));\n            }\n\n            // 3. Check if project has any *valid* version source.\n            //    resolve_node_version returns Some for any non-empty value,\n            //    even invalid ones. We must validate before routing to\n            //    download_runtime_for_project, which falls to LTS on all-invalid\n            //    and would skip the user's configured default.\n            let has_valid_project_source = has_valid_version_source(project_path).await?;\n\n            let runtime = if has_valid_project_source {\n                // At least one valid project source exists — delegate to\n                // download_runtime_for_project for cache-aware range resolution\n                // and intra-project fallback chain\n                download_runtime_for_project(project_path).await?\n            } else {\n                // No valid project source — check user default from config, then LTS\n                let resolution = config::resolve_version(project_path).await?;\n                download_runtime(JsRuntimeType::Node, &resolution.version).await?\n            };\n            self.project_runtime = Some(runtime);\n        }\n        Ok(self.project_runtime.as_ref().unwrap())\n    }\n\n    /// Download a specific Node.js version.\n    ///\n    /// This is used when we need a specific version regardless of\n    /// package.json configuration.\n    #[allow(dead_code)] // Will be used in future phases\n    pub async fn download_node(&self, version: &str) -> Result<JsRuntime, Error> {\n        Ok(download_runtime(JsRuntimeType::Node, version).await?)\n    }\n\n    /// Delegate to local or global vite-plus CLI.\n    ///\n    /// Uses `oxc_resolver` to find the project's local vite-plus installation.\n    /// If found, runs the local `dist/bin.js` directly. Otherwise, falls back\n    /// to the global installation's `dist/bin.js`.\n    ///\n    /// Uses the project's runtime resolved via `config::resolve_version()`.\n    /// For side-effect-free commands like `--version`, use [`delegate_with_cli_runtime`] instead.\n    ///\n    /// # Arguments\n    /// * `project_path` - Path to the project directory\n    /// * `args` - Arguments to pass to the local CLI\n    pub async fn delegate_to_local_cli(\n        &mut self,\n        project_path: &AbsolutePath,\n        args: &[String],\n    ) -> Result<ExitStatus, Error> {\n        // Use project's runtime based on its devEngines.runtime configuration\n        let runtime = self.ensure_project_runtime(project_path).await?;\n        let node_binary = runtime.get_binary_path();\n        let bin_prefix = runtime.get_bin_prefix();\n        self.run_js_entry(project_path, &node_binary, &bin_prefix, args).await\n    }\n\n    /// Delegate to the global vite-plus CLI entrypoint directly.\n    ///\n    /// Unlike [`delegate_to_local_cli`], this bypasses project-local resolution and always runs\n    /// the global installation's `dist/bin.js`.\n    pub async fn delegate_to_global_cli(\n        &mut self,\n        project_path: &AbsolutePath,\n        args: &[String],\n    ) -> Result<ExitStatus, Error> {\n        let runtime = self.ensure_cli_runtime().await?;\n        let node_binary = runtime.get_binary_path();\n        let bin_prefix = runtime.get_bin_prefix();\n        let scripts_dir = self.get_scripts_dir()?;\n        let entry_point = scripts_dir.join(\"bin.js\");\n\n        let mut cmd = Self::create_js_command(&node_binary, &bin_prefix);\n        cmd.arg(entry_point.as_path()).args(args).current_dir(project_path.as_path());\n\n        let status = cmd.status().await?;\n        Ok(status)\n    }\n\n    /// Delegate to local or global vite-plus CLI using the CLI's own runtime.\n    ///\n    /// Like [`delegate_to_local_cli`], but uses the CLI's bundled runtime\n    /// (from its own `devEngines.runtime` in `package.json`) instead of the\n    /// project's runtime. This avoids side effects like writing `.node-version`\n    /// when no version source exists in the project directory.\n    ///\n    /// Use this for read-only / side-effect-free commands like `--version`.\n    #[allow(dead_code)] // kept for future read-only delegations\n    pub async fn delegate_with_cli_runtime(\n        &mut self,\n        project_path: &AbsolutePath,\n        args: &[String],\n    ) -> Result<ExitStatus, Error> {\n        let runtime = self.ensure_cli_runtime().await?;\n        let node_binary = runtime.get_binary_path();\n        let bin_prefix = runtime.get_bin_prefix();\n        self.run_js_entry(project_path, &node_binary, &bin_prefix, args).await\n    }\n\n    /// Run a JS entry point with the given runtime, resolving local vite-plus first.\n    async fn run_js_entry(\n        &self,\n        project_path: &AbsolutePath,\n        node_binary: &AbsolutePath,\n        bin_prefix: &AbsolutePath,\n        args: &[String],\n    ) -> Result<ExitStatus, Error> {\n        // Try to resolve vite-plus from the project directory using oxc_resolver\n        let entry_point = match Self::resolve_local_vite_plus(project_path) {\n            Some(path) => path,\n            None => {\n                // Fall back to the global installation's bin.js\n                let scripts_dir = self.get_scripts_dir()?;\n                scripts_dir.join(\"bin.js\")\n            }\n        };\n\n        tracing::debug!(\"Delegating to CLI via JS entry point: {:?} {:?}\", entry_point, args);\n\n        let mut cmd = Self::create_js_command(node_binary, bin_prefix);\n        cmd.arg(entry_point.as_path()).args(args).current_dir(project_path.as_path());\n\n        let status = cmd.status().await?;\n        Ok(status)\n    }\n\n    /// Resolve the local vite-plus package's `dist/bin.js` from the project directory.\n    fn resolve_local_vite_plus(project_path: &AbsolutePath) -> Option<AbsolutePathBuf> {\n        use oxc_resolver::{ResolveOptions, Resolver};\n\n        let resolver = Resolver::new(ResolveOptions {\n            condition_names: vec![\"import\".into(), \"node\".into()],\n            ..ResolveOptions::default()\n        });\n\n        // Resolve vite-plus/package.json from the project directory to find the package root\n        let resolved = resolver.resolve(project_path, \"vite-plus/package.json\").ok()?;\n        let pkg_dir = resolved.path().parent()?;\n        let bin_js = pkg_dir.join(\"dist\").join(\"bin.js\");\n\n        if bin_js.exists() {\n            tracing::debug!(\"Found local vite-plus at {:?}\", bin_js);\n            AbsolutePathBuf::new(bin_js)\n        } else {\n            tracing::debug!(\"Local vite-plus found but dist/bin.js missing at {:?}\", bin_js);\n            None\n        }\n    }\n}\n\n/// Check whether a project directory has at least one valid version source.\n///\n/// Uses `is_valid_version` (no warning side effects) to avoid duplicate\n/// warnings when `download_runtime_for_project` or `config::resolve_version`\n/// later call `normalize_version` on the same values.\n///\n/// Returns `false` when all sources are missing or invalid, so the caller\n/// can fall through to the user's configured default instead of LTS.\nasync fn has_valid_version_source(\n    project_path: &AbsolutePath,\n) -> Result<bool, vite_js_runtime::Error> {\n    let resolution = resolve_node_version(project_path, true).await?;\n    let Some(ref r) = resolution else {\n        return Ok(false);\n    };\n\n    // Primary source is a valid version?\n    if is_valid_version(&r.version) {\n        return Ok(true);\n    }\n\n    // Primary source invalid — check package.json for valid fallbacks\n    let pkg_path = project_path.join(\"package.json\");\n    let Ok(Some(pkg)) = read_package_json(&pkg_path).await else {\n        return Ok(false);\n    };\n\n    let engines_valid =\n        pkg.engines.as_ref().and_then(|e| e.node.as_ref()).is_some_and(|v| is_valid_version(v));\n\n    let dev_engines_valid = !engines_valid\n        && pkg\n            .dev_engines\n            .as_ref()\n            .and_then(|de| de.runtime.as_ref())\n            .and_then(|rt| rt.find_by_name(\"node\"))\n            .filter(|r| !r.version.is_empty())\n            .is_some_and(|r| is_valid_version(&r.version));\n\n    Ok(engines_valid || dev_engines_valid)\n}\n\n#[cfg(test)]\nmod tests {\n    use serial_test::serial;\n\n    use super::*;\n\n    #[test]\n    fn test_js_executor_new() {\n        let executor = JsExecutor::new(None);\n        assert!(executor.cli_runtime.is_none());\n        assert!(executor.project_runtime.is_none());\n        assert!(executor.scripts_dir.is_none());\n    }\n\n    #[test]\n    fn test_js_executor_with_scripts_dir() {\n        let scripts_dir = if cfg!(windows) {\n            AbsolutePathBuf::new(\"C:\\\\test\\\\scripts\".into()).unwrap()\n        } else {\n            AbsolutePathBuf::new(\"/test/scripts\".into()).unwrap()\n        };\n\n        let executor = JsExecutor::new(Some(scripts_dir.clone()));\n        assert_eq!(executor.get_scripts_dir().unwrap(), scripts_dir);\n    }\n\n    #[test]\n    fn test_create_js_command_uses_direct_binary() {\n        use std::ffi::OsStr;\n\n        let (runtime_binary, runtime_bin_prefix, expected_program) = if cfg!(windows) {\n            (\n                AbsolutePathBuf::new(\"C:\\\\node\\\\node.exe\".into()).unwrap(),\n                AbsolutePathBuf::new(\"C:\\\\node\".into()).unwrap(),\n                \"C:\\\\node\\\\node.exe\",\n            )\n        } else {\n            (\n                AbsolutePathBuf::new(\"/usr/local/bin/node\".into()).unwrap(),\n                AbsolutePathBuf::new(\"/usr/local/bin\".into()).unwrap(),\n                \"/usr/local/bin/node\",\n            )\n        };\n\n        let cmd = JsExecutor::create_js_command(&runtime_binary, &runtime_bin_prefix);\n\n        // The command should use the node binary directly\n        assert_eq!(cmd.as_std().get_program(), OsStr::new(expected_program));\n    }\n\n    #[tokio::test]\n    #[serial]\n    async fn test_delegate_to_local_cli_prints_node_version() {\n        use std::io::Write;\n\n        use tempfile::TempDir;\n\n        // Create a temporary directory for the scripts (used as fallback global dir)\n        let temp_dir = TempDir::new().unwrap();\n        let scripts_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create a bin.js that prints process.version\n        let script_path = temp_dir.path().join(\"bin.js\");\n        let mut file = std::fs::File::create(&script_path).unwrap();\n        writeln!(file, \"console.log(process.version);\").unwrap();\n\n        // Create executor with the temp scripts directory as global fallback\n        let mut executor = JsExecutor::new(Some(scripts_dir.clone()));\n\n        // Delegate — no local vite-plus will be found, so it falls back to global bin.js\n        let status = executor.delegate_to_local_cli(&scripts_dir, &[]).await.unwrap();\n\n        assert!(status.success(), \"Script should execute successfully\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/main.rs",
    "content": "//! Vite+ Global CLI\n//!\n//! A standalone Rust binary for the vite+ global CLI that can run without\n//! pre-installed Node.js. Uses managed Node.js from `vite_js_runtime` for\n//! package manager commands and JS script execution.\n\n// Allow printing to stderr for CLI error messages\n#![allow(clippy::print_stderr)]\n\nmod cli;\nmod command_picker;\nmod commands;\nmod error;\nmod help;\nmod js_executor;\nmod shim;\nmod tips;\n\nuse std::{\n    io::{IsTerminal, Write},\n    process::{ExitCode, ExitStatus},\n};\n\nuse clap::error::{ContextKind, ContextValue};\nuse owo_colors::OwoColorize;\nuse vite_shared::output;\n\npub use crate::cli::try_parse_args_from;\nuse crate::cli::{\n    RenderOptions, run_command, run_command_with_options, try_parse_args_from_with_options,\n};\n\n/// Normalize CLI arguments:\n/// - `vp list ...` / `vp ls ...` → `vp pm list ...`\n/// - `vp help [command]` → `vp [command] --help`\nfn normalize_args(args: Vec<String>) -> Vec<String> {\n    match args.get(1).map(String::as_str) {\n        // `vp list ...` → `vp pm list ...`\n        // `vp ls ...` → `vp pm list ...`\n        Some(\"list\" | \"ls\") => {\n            let mut normalized = Vec::with_capacity(args.len() + 1);\n            normalized.push(args[0].clone());\n            normalized.push(\"pm\".to_string());\n            normalized.push(\"list\".to_string());\n            normalized.extend(args[2..].iter().cloned());\n            normalized\n        }\n        // `vp help` alone -> show main help\n        Some(\"help\") if args.len() == 2 => vec![args[0].clone(), \"--help\".to_string()],\n        // `vp help [command] [args...]` -> `vp [command] --help [args...]`\n        Some(\"help\") if args.len() > 2 => {\n            let mut normalized = Vec::with_capacity(args.len());\n            normalized.push(args[0].clone());\n            normalized.push(args[2].clone());\n            normalized.push(\"--help\".to_string());\n            normalized.extend(args[3..].iter().cloned());\n            normalized\n        }\n        // No transformation needed\n        _ => args,\n    }\n}\n\nstruct InvalidSubcommandDetails {\n    invalid_subcommand: String,\n    suggestion: Option<String>,\n}\n\nfn extract_invalid_subcommand_details(error: &clap::Error) -> Option<InvalidSubcommandDetails> {\n    let invalid_subcommand = match error.get(ContextKind::InvalidSubcommand) {\n        Some(ContextValue::String(value)) => value.as_str(),\n        _ => return None,\n    };\n\n    let suggestion = match error.get(ContextKind::SuggestedSubcommand) {\n        Some(ContextValue::String(value)) => Some(value.to_owned()),\n        Some(ContextValue::Strings(values)) => {\n            vite_shared::string_similarity::pick_best_suggestion(invalid_subcommand, values)\n        }\n        _ => None,\n    };\n\n    Some(InvalidSubcommandDetails { invalid_subcommand: invalid_subcommand.to_owned(), suggestion })\n}\n\nfn print_invalid_subcommand_error(details: &InvalidSubcommandDetails) {\n    println!(\"{}\", vite_shared::header::vite_plus_header());\n    println!();\n\n    let highlighted_subcommand = details.invalid_subcommand.bright_blue().to_string();\n    output::error(&format!(\"Command '{highlighted_subcommand}' not found\"));\n}\n\nfn is_affirmative_response(input: &str) -> bool {\n    matches!(input.trim().to_ascii_lowercase().as_str(), \"y\" | \"yes\" | \"ok\" | \"true\" | \"1\")\n}\n\nfn should_prompt_for_correction() -> bool {\n    std::io::stdin().is_terminal() && std::io::stderr().is_terminal()\n}\n\nfn prompt_to_run_suggested_command(suggestion: &str) -> bool {\n    if !should_prompt_for_correction() {\n        return false;\n    }\n\n    eprintln!();\n    let highlighted_suggestion = format!(\"`vp {suggestion}`\").bright_blue().to_string();\n    eprint!(\"Do you want to run {highlighted_suggestion}? (y/N): \");\n    if std::io::stderr().flush().is_err() {\n        return false;\n    }\n\n    let Some(input) = read_confirmation_input() else {\n        return false;\n    };\n\n    is_affirmative_response(input.trim())\n}\n\nfn read_confirmation_input() -> Option<String> {\n    let mut input = String::new();\n    std::io::stdin().read_line(&mut input).ok()?;\n    Some(input)\n}\n\nfn replace_top_level_typoed_subcommand(\n    raw_args: &[String],\n    invalid_subcommand: &str,\n    suggestion: &str,\n) -> Option<Vec<String>> {\n    let index = raw_args.iter().position(|arg| !arg.starts_with('-'))?;\n    if raw_args.get(index)? != invalid_subcommand {\n        return None;\n    }\n\n    let mut corrected = raw_args.to_vec();\n    corrected[index] = suggestion.to_owned();\n    Some(corrected)\n}\n\nfn exit_status_to_exit_code(exit_status: ExitStatus) -> ExitCode {\n    if exit_status.success() {\n        ExitCode::SUCCESS\n    } else {\n        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]\n        exit_status.code().map_or(ExitCode::FAILURE, |c| ExitCode::from(c as u8))\n    }\n}\n\nasync fn run_corrected_args(cwd: &vite_path::AbsolutePathBuf, raw_args: &[String]) -> ExitCode {\n    let render_options = RenderOptions { show_header: false };\n    let args_with_program = std::iter::once(\"vp\".to_string()).chain(raw_args.iter().cloned());\n    let normalized_args = normalize_args(args_with_program.collect());\n\n    let parsed = match try_parse_args_from_with_options(normalized_args, render_options) {\n        Ok(args) => args,\n        Err(e) => {\n            e.print().ok();\n            #[allow(clippy::cast_sign_loss)]\n            return ExitCode::from(e.exit_code() as u8);\n        }\n    };\n\n    match run_command_with_options(cwd.clone(), parsed, render_options).await {\n        Ok(exit_status) => exit_status_to_exit_code(exit_status),\n        Err(e) => {\n            if matches!(&e, error::Error::UserMessage(_)) {\n                eprintln!(\"{e}\");\n            } else {\n                output::error(&format!(\"{e}\"));\n            }\n            ExitCode::FAILURE\n        }\n    }\n}\n\nfn extract_unknown_argument(error: &clap::Error) -> Option<String> {\n    match error.get(ContextKind::InvalidArg) {\n        Some(ContextValue::String(value)) => Some(value.to_owned()),\n        _ => None,\n    }\n}\n\nfn has_pass_as_value_suggestion(error: &clap::Error) -> bool {\n    let contains_pass_as_value = |suggestion: &str| suggestion.contains(\"as a value\");\n\n    match error.get(ContextKind::Suggested) {\n        Some(ContextValue::String(suggestion)) => contains_pass_as_value(suggestion),\n        Some(ContextValue::Strings(suggestions)) => {\n            suggestions.iter().any(|suggestion| contains_pass_as_value(suggestion))\n        }\n        Some(ContextValue::StyledStr(suggestion)) => {\n            contains_pass_as_value(&suggestion.to_string())\n        }\n        Some(ContextValue::StyledStrs(suggestions)) => {\n            suggestions.iter().any(|suggestion| contains_pass_as_value(&suggestion.to_string()))\n        }\n        _ => false,\n    }\n}\n\nfn print_unknown_argument_error(error: &clap::Error) -> bool {\n    let Some(invalid_argument) = extract_unknown_argument(error) else {\n        return false;\n    };\n\n    println!(\"{}\", vite_shared::header::vite_plus_header());\n    println!();\n\n    let highlighted_argument = invalid_argument.bright_blue().to_string();\n    output::error(&format!(\"Unexpected argument '{highlighted_argument}'\"));\n\n    if has_pass_as_value_suggestion(error) {\n        eprintln!();\n        let pass_through_argument = format!(\"-- {invalid_argument}\");\n        let highlighted_pass_through_argument =\n            format!(\"`{}`\", pass_through_argument.bright_blue());\n        eprintln!(\"Use {highlighted_pass_through_argument} to pass the argument as a value\");\n    }\n\n    true\n}\n\n#[tokio::main]\nasync fn main() -> ExitCode {\n    // Initialize tracing\n    vite_shared::init_tracing();\n\n    // Check for shim mode (invoked as node, npm, or npx)\n    let mut args: Vec<String> = std::env::args().collect();\n    let argv0 = args.first().map(|s| s.as_str()).unwrap_or(\"vp\");\n    tracing::debug!(\"argv0: {argv0}\");\n\n    if let Some(tool) = shim::detect_shim_tool(argv0) {\n        // Shim mode - dispatch to the appropriate tool\n        let exit_code = shim::dispatch(&tool, &args[1..]).await;\n        return ExitCode::from(exit_code as u8);\n    }\n\n    // Normal CLI mode - get current working directory\n    let cwd = match vite_path::current_dir() {\n        Ok(path) => path,\n        Err(e) => {\n            output::error(&format!(\"Failed to get current directory: {e}\"));\n            return ExitCode::FAILURE;\n        }\n    };\n\n    if args.len() == 1 {\n        match command_picker::pick_top_level_command_if_interactive(&cwd) {\n            Ok(command_picker::TopLevelCommandPick::Selected(selection)) => {\n                args.push(selection.command.to_string());\n                if selection.append_help {\n                    args.push(\"--help\".to_string());\n                }\n            }\n            Ok(command_picker::TopLevelCommandPick::Cancelled) => {\n                return ExitCode::SUCCESS;\n            }\n            Ok(command_picker::TopLevelCommandPick::Skipped) => {}\n            Err(err) => {\n                tracing::debug!(\"Failed to run top-level command picker: {err}\");\n            }\n        }\n    }\n\n    let mut tip_context = tips::TipContext {\n        // Capture user args (excluding argv0) before normalization\n        raw_args: args[1..].to_vec(),\n        ..Default::default()\n    };\n\n    // Normalize arguments (list/ls aliases, help rewriting)\n    let normalized_args = normalize_args(args);\n\n    // Print unified subcommand help for clap-managed commands before clap handles help output.\n    if help::maybe_print_unified_clap_subcommand_help(&normalized_args) {\n        return ExitCode::SUCCESS;\n    }\n\n    // Parse CLI arguments (using custom help formatting)\n    let exit_code = match try_parse_args_from(normalized_args) {\n        Err(e) => {\n            use clap::error::ErrorKind;\n\n            // --help and --version are clap \"errors\" but should exit successfully.\n            if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {\n                e.print().ok();\n                ExitCode::SUCCESS\n            } else if matches!(e.kind(), ErrorKind::InvalidSubcommand) {\n                if let Some(details) = extract_invalid_subcommand_details(&e) {\n                    print_invalid_subcommand_error(&details);\n\n                    if let Some(suggestion) = &details.suggestion {\n                        if let Some(corrected_raw_args) = replace_top_level_typoed_subcommand(\n                            &tip_context.raw_args,\n                            &details.invalid_subcommand,\n                            suggestion,\n                        ) {\n                            if prompt_to_run_suggested_command(suggestion) {\n                                tip_context.raw_args = corrected_raw_args.clone();\n                                run_corrected_args(&cwd, &corrected_raw_args).await\n                            } else {\n                                let code = e.exit_code();\n                                tip_context.clap_error = Some(e);\n                                #[allow(clippy::cast_sign_loss)]\n                                ExitCode::from(code as u8)\n                            }\n                        } else {\n                            let code = e.exit_code();\n                            tip_context.clap_error = Some(e);\n                            #[allow(clippy::cast_sign_loss)]\n                            ExitCode::from(code as u8)\n                        }\n                    } else {\n                        let code = e.exit_code();\n                        tip_context.clap_error = Some(e);\n                        #[allow(clippy::cast_sign_loss)]\n                        ExitCode::from(code as u8)\n                    }\n                } else {\n                    e.print().ok();\n                    let code = e.exit_code();\n                    tip_context.clap_error = Some(e);\n                    #[allow(clippy::cast_sign_loss)]\n                    ExitCode::from(code as u8)\n                }\n            } else if matches!(e.kind(), ErrorKind::UnknownArgument) {\n                if !print_unknown_argument_error(&e) {\n                    e.print().ok();\n                }\n                let code = e.exit_code();\n                tip_context.clap_error = Some(e);\n                #[allow(clippy::cast_sign_loss)]\n                ExitCode::from(code as u8)\n            } else {\n                e.print().ok();\n                let code = e.exit_code();\n                tip_context.clap_error = Some(e);\n                #[allow(clippy::cast_sign_loss)]\n                ExitCode::from(code as u8)\n            }\n        }\n        Ok(args) => match run_command(cwd.clone(), args).await {\n            Ok(exit_status) => exit_status_to_exit_code(exit_status),\n            Err(e) => {\n                if matches!(&e, error::Error::UserMessage(_)) {\n                    eprintln!(\"{e}\");\n                } else {\n                    output::error(&format!(\"{e}\"));\n                }\n                ExitCode::FAILURE\n            }\n        },\n    };\n\n    tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 };\n\n    if let Some(tip) = tips::get_tip(&tip_context) {\n        eprintln!(\"\\n{} {}\", \"tip:\".bright_black().bold(), tip.bright_black());\n    }\n\n    exit_code\n}\n\n#[cfg(test)]\nmod tests {\n    use clap::error::ErrorKind;\n\n    use super::{\n        extract_unknown_argument, has_pass_as_value_suggestion, is_affirmative_response,\n        replace_top_level_typoed_subcommand, try_parse_args_from,\n    };\n\n    #[test]\n    fn unknown_argument_detected_without_pass_as_value_hint() {\n        let error = try_parse_args_from([\"vp\".to_string(), \"--cache\".to_string()])\n            .expect_err(\"Expected parse error\");\n        assert_eq!(error.kind(), ErrorKind::UnknownArgument);\n        assert_eq!(extract_unknown_argument(&error).as_deref(), Some(\"--cache\"));\n        assert!(!has_pass_as_value_suggestion(&error));\n    }\n\n    #[test]\n    fn unknown_argument_detected_with_pass_as_value_hint() {\n        let error = try_parse_args_from([\n            \"vp\".to_string(),\n            \"remove\".to_string(),\n            \"--stream\".to_string(),\n            \"foo\".to_string(),\n        ])\n        .expect_err(\"Expected parse error\");\n        assert_eq!(error.kind(), ErrorKind::UnknownArgument);\n        assert_eq!(extract_unknown_argument(&error).as_deref(), Some(\"--stream\"));\n        assert!(has_pass_as_value_suggestion(&error));\n    }\n\n    #[test]\n    fn affirmative_response_detection() {\n        assert!(is_affirmative_response(\"y\"));\n        assert!(is_affirmative_response(\"yes\"));\n        assert!(is_affirmative_response(\"Y\"));\n        assert!(!is_affirmative_response(\"Sure\"));\n        assert!(!is_affirmative_response(\"n\"));\n        assert!(!is_affirmative_response(\"\"));\n    }\n\n    #[test]\n    fn replace_top_level_typoed_subcommand_preserves_trailing_args() {\n        let raw_args = vec![\"fnt\".to_string(), \"--write\".to_string(), \"src\".to_string()];\n        let corrected = replace_top_level_typoed_subcommand(&raw_args, \"fnt\", \"fmt\")\n            .expect(\"Expected typoed command to be replaced\");\n        assert_eq!(corrected, vec![\"fmt\".to_string(), \"--write\".to_string(), \"src\".to_string()]);\n    }\n\n    #[test]\n    fn replace_top_level_typoed_subcommand_skips_nested_subcommands() {\n        let raw_args = vec![\"env\".to_string(), \"typo\".to_string()];\n        let corrected = replace_top_level_typoed_subcommand(&raw_args, \"typo\", \"on\");\n        assert!(corrected.is_none());\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/shim/cache.rs",
    "content": "//! Resolution cache for shim operations.\n//!\n//! Caches version resolution results to avoid re-resolving on every invocation.\n//! Uses mtime-based invalidation to detect changes in version source files.\n\nuse std::{\n    collections::HashMap,\n    time::{SystemTime, UNIX_EPOCH},\n};\n\nuse serde::{Deserialize, Serialize};\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\n\n/// Cache format version for upgrade compatibility\n/// v2: Added `is_range` field to track range vs exact version for cache expiry\nconst CACHE_VERSION: u32 = 2;\n\n/// Default maximum cache entries (LRU eviction)\nconst DEFAULT_MAX_ENTRIES: usize = 4096;\n\n/// A single cache entry for a resolved version.\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct ResolveCacheEntry {\n    /// The resolved version string (e.g., \"20.18.0\")\n    pub version: String,\n    /// The source of the version (e.g., \".node-version\", \"engines.node\")\n    pub source: String,\n    /// Project root directory (if applicable)\n    pub project_root: Option<String>,\n    /// Unix timestamp when this entry was resolved\n    pub resolved_at: u64,\n    /// Mtime of the version source file (for invalidation)\n    pub version_file_mtime: u64,\n    /// Path to the version source file\n    pub source_path: Option<String>,\n    /// Whether the original version spec was a range (e.g., \"20\", \"^20.0.0\", \"lts/*\")\n    /// Range versions use time-based expiry (1 hour) instead of mtime-only validation\n    #[serde(default)]\n    pub is_range: bool,\n}\n\n/// Resolution cache stored in VITE_PLUS_HOME/cache/resolve_cache.json.\n#[derive(Serialize, Deserialize, Debug)]\npub struct ResolveCache {\n    /// Cache format version for upgrade compatibility\n    version: u32,\n    /// Cache entries keyed by current working directory\n    entries: HashMap<String, ResolveCacheEntry>,\n}\n\nimpl Default for ResolveCache {\n    fn default() -> Self {\n        Self { version: CACHE_VERSION, entries: HashMap::new() }\n    }\n}\n\nimpl ResolveCache {\n    /// Load cache from disk.\n    pub fn load(cache_path: &AbsolutePath) -> Self {\n        match std::fs::read_to_string(cache_path) {\n            Ok(content) => {\n                match serde_json::from_str::<Self>(&content) {\n                    Ok(cache) if cache.version == CACHE_VERSION => cache,\n                    Ok(_) => {\n                        // Version mismatch, reset cache\n                        tracing::debug!(\"Cache version mismatch, resetting\");\n                        Self::default()\n                    }\n                    Err(e) => {\n                        tracing::debug!(\"Failed to parse cache: {e}\");\n                        Self::default()\n                    }\n                }\n            }\n            Err(_) => Self::default(),\n        }\n    }\n\n    /// Save cache to disk.\n    pub fn save(&self, cache_path: &AbsolutePath) {\n        // Ensure parent directory exists\n        if let Some(parent) = cache_path.parent() {\n            std::fs::create_dir_all(parent).ok();\n        }\n\n        if let Ok(content) = serde_json::to_string(self) {\n            std::fs::write(cache_path, content).ok();\n        }\n    }\n\n    /// Get a cache entry if valid.\n    pub fn get(&self, cwd: &AbsolutePath) -> Option<&ResolveCacheEntry> {\n        let key = cwd.as_path().to_string_lossy().to_string();\n        let entry = self.entries.get(&key)?;\n\n        // Validate mtime of source file\n        if !self.is_entry_valid(entry) {\n            return None;\n        }\n\n        Some(entry)\n    }\n\n    /// Insert a cache entry.\n    pub fn insert(&mut self, cwd: &AbsolutePath, entry: ResolveCacheEntry) {\n        let key = cwd.as_path().to_string_lossy().to_string();\n\n        // LRU eviction if needed\n        if self.entries.len() >= DEFAULT_MAX_ENTRIES {\n            self.evict_oldest();\n        }\n\n        self.entries.insert(key, entry);\n    }\n\n    /// Check if an entry is still valid based on source file mtime and range status.\n    ///\n    /// For exact versions: Uses mtime-based validation only (cache valid until file changes)\n    /// For range versions: Uses both mtime AND time-based expiry (1 hour TTL)\n    ///\n    /// This ensures range versions like \"20\" or \"^20.0.0\" are periodically re-resolved\n    /// to pick up new releases, while exact versions like \"20.18.0\" only re-resolve\n    /// when the source file is modified.\n    fn is_entry_valid(&self, entry: &ResolveCacheEntry) -> bool {\n        // For range versions (including LTS aliases), always apply time-based expiry\n        // This ensures we periodically re-resolve to pick up new releases\n        if entry.is_range {\n            let now =\n                SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);\n            if now.saturating_sub(entry.resolved_at) >= 3600 {\n                // Range cache expired (> 1 hour)\n                return false;\n            }\n            // Range cache still within TTL, but also check mtime if source_path exists\n            if let Some(source_path) = &entry.source_path {\n                let path = std::path::Path::new(source_path);\n                if let Ok(metadata) = std::fs::metadata(path) {\n                    if let Ok(mtime) = metadata.modified() {\n                        let mtime_secs =\n                            mtime.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);\n                        return mtime_secs == entry.version_file_mtime;\n                    }\n                }\n                return false; // Source file missing or can't read mtime\n            }\n            return true; // No source file, within TTL\n        }\n\n        // For exact versions, check source file\n        let Some(source_path) = &entry.source_path else {\n            // No source file to validate (e.g., \"lts\" default)\n            // Consider valid if resolved recently (within 1 hour)\n            let now =\n                SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);\n            return now.saturating_sub(entry.resolved_at) < 3600;\n        };\n\n        let path = std::path::Path::new(source_path);\n        let Ok(metadata) = std::fs::metadata(path) else {\n            return false;\n        };\n\n        let Ok(mtime) = metadata.modified() else {\n            return false;\n        };\n\n        let mtime_secs = mtime.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);\n\n        mtime_secs == entry.version_file_mtime\n    }\n\n    /// Evict the oldest entry (by resolved_at timestamp).\n    fn evict_oldest(&mut self) {\n        if let Some((oldest_key, _)) = self\n            .entries\n            .iter()\n            .min_by_key(|(_, entry)| entry.resolved_at)\n            .map(|(k, v)| (k.clone(), v.clone()))\n        {\n            self.entries.remove(&oldest_key);\n        }\n    }\n}\n\n/// Get the cache file path.\npub fn get_cache_path() -> Option<AbsolutePathBuf> {\n    let home = crate::commands::env::config::get_vite_plus_home().ok()?;\n    Some(home.join(\"cache\").join(\"resolve_cache.json\"))\n}\n\n/// Invalidate the entire resolve cache by deleting the cache file.\n/// Called after version configuration changes (e.g., `vp env default`, `vp env pin`, `vp env unpin`).\npub fn invalidate_cache() {\n    if let Some(cache_path) = get_cache_path() {\n        std::fs::remove_file(cache_path.as_path()).ok();\n    }\n}\n\n/// Get the mtime of a file as Unix timestamp.\npub fn get_file_mtime(path: &AbsolutePath) -> Option<u64> {\n    let metadata = std::fs::metadata(path).ok()?;\n    let mtime = metadata.modified().ok()?;\n    mtime.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).ok()\n}\n\n/// Get the current Unix timestamp.\npub fn now_timestamp() -> u64 {\n    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[test]\n    fn test_range_version_cache_should_expire_after_ttl() {\n        // BUG: Currently, range versions with source_path use mtime-only validation\n        // and never expire. They should use time-based expiry like aliases.\n\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let cache_file = temp_path.join(\"cache.json\");\n\n        // Create a .node-version file\n        let version_file = temp_path.join(\".node-version\");\n        std::fs::write(&version_file, \"20\\n\").unwrap();\n        let mtime =\n            get_file_mtime(&version_file).expect(\"Should be able to get mtime of created file\");\n\n        let mut cache = ResolveCache::default();\n\n        // Create an entry for a range version (e.g., \"20\" resolved to \"20.20.0\")\n        // with source_path set (from .node-version file) and resolved 2 hours ago\n        let entry = ResolveCacheEntry {\n            version: \"20.20.0\".to_string(),\n            source: \".node-version\".to_string(),\n            project_root: None,\n            resolved_at: now_timestamp() - 7200, // 2 hours ago (> 1 hour TTL)\n            version_file_mtime: mtime,\n            source_path: Some(version_file.as_path().display().to_string()),\n            // BUG FIX: need to add is_range field\n            is_range: true,\n        };\n\n        // Save entry to cache\n        cache.insert(&temp_path, entry.clone());\n        cache.save(&cache_file);\n\n        // Reload cache\n        let loaded_cache = ResolveCache::load(&cache_file);\n\n        // BUG: This entry is still considered valid because mtime hasn't changed\n        // but it SHOULD be invalid because it's a range and TTL has expired\n        // After fix: is_entry_valid should return false for expired range entries\n        let cached_entry = loaded_cache.get(&temp_path);\n\n        // The cache entry should be INVALID (None) because:\n        // 1. is_range is true\n        // 2. resolved_at is > 1 hour ago\n        // Even though the mtime hasn't changed\n        assert!(\n            cached_entry.is_none(),\n            \"Range version cache should expire after 1 hour TTL, \\\n             but mtime-only validation is returning the stale entry\"\n        );\n    }\n\n    #[test]\n    fn test_exact_version_cache_uses_mtime_validation() {\n        // Exact versions should use mtime-based validation, not time-based expiry\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let cache_file = temp_path.join(\"cache.json\");\n\n        // Create a .node-version file\n        let version_file = temp_path.join(\".node-version\");\n        std::fs::write(&version_file, \"20.18.0\\n\").unwrap();\n        let mtime = get_file_mtime(&version_file).unwrap();\n\n        let mut cache = ResolveCache::default();\n\n        // Create an entry for an exact version resolved 2 hours ago\n        let entry = ResolveCacheEntry {\n            version: \"20.18.0\".to_string(),\n            source: \".node-version\".to_string(),\n            project_root: None,\n            resolved_at: now_timestamp() - 7200, // 2 hours ago\n            version_file_mtime: mtime,\n            source_path: Some(version_file.as_path().display().to_string()),\n            is_range: false, // Exact version, not a range\n        };\n\n        cache.insert(&temp_path, entry);\n        cache.save(&cache_file);\n\n        // Reload cache\n        let loaded_cache = ResolveCache::load(&cache_file);\n        let cached_entry = loaded_cache.get(&temp_path);\n\n        // Exact version cache should still be valid as long as mtime hasn't changed\n        assert!(\n            cached_entry.is_some(),\n            \"Exact version cache should use mtime validation, not time-based expiry\"\n        );\n        assert_eq!(cached_entry.unwrap().version, \"20.18.0\");\n    }\n\n    #[test]\n    fn test_range_cache_valid_within_ttl() {\n        // Range version cache should be valid within the 1 hour TTL\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let cache_file = temp_path.join(\"cache.json\");\n\n        // Create a .node-version file\n        let version_file = temp_path.join(\".node-version\");\n        std::fs::write(&version_file, \"20\\n\").unwrap();\n        let mtime = get_file_mtime(&version_file).unwrap();\n\n        let mut cache = ResolveCache::default();\n\n        // Create an entry for a range version resolved recently (30 minutes ago)\n        let entry = ResolveCacheEntry {\n            version: \"20.20.0\".to_string(),\n            source: \".node-version\".to_string(),\n            project_root: None,\n            resolved_at: now_timestamp() - 1800, // 30 minutes ago (< 1 hour TTL)\n            version_file_mtime: mtime,\n            source_path: Some(version_file.as_path().display().to_string()),\n            is_range: true,\n        };\n\n        cache.insert(&temp_path, entry);\n        cache.save(&cache_file);\n\n        // Reload cache\n        let loaded_cache = ResolveCache::load(&cache_file);\n        let cached_entry = loaded_cache.get(&temp_path);\n\n        // Range version cache should still be valid within TTL\n        assert!(cached_entry.is_some(), \"Range version cache should be valid within TTL\");\n        assert_eq!(cached_entry.unwrap().version, \"20.20.0\");\n    }\n\n    // Run serially: mutates VITE_PLUS_HOME env var which affects get_cache_path()\n    #[test]\n    #[serial_test::serial]\n    fn test_invalidate_cache_removes_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Set VITE_PLUS_HOME to temp dir so invalidate_cache() targets our test file\n        let cache_dir = temp_path.join(\"cache\");\n        std::fs::create_dir_all(&cache_dir).unwrap();\n        let cache_file = cache_dir.join(\"resolve_cache.json\");\n\n        // Create a cache with an entry and save it\n        let mut cache = ResolveCache::default();\n        cache.insert(\n            &temp_path,\n            ResolveCacheEntry {\n                version: \"20.18.0\".to_string(),\n                source: \".node-version\".to_string(),\n                project_root: None,\n                resolved_at: now_timestamp(),\n                version_file_mtime: 0,\n                source_path: None,\n                is_range: false,\n            },\n        );\n        cache.save(&cache_file);\n        assert!(std::fs::metadata(cache_file.as_path()).is_ok(), \"Cache file should exist\");\n\n        // Point VITE_PLUS_HOME to our temp dir and call invalidate_cache\n        unsafe {\n            std::env::set_var(vite_shared::env_vars::VITE_PLUS_HOME, temp_path.as_path());\n        }\n        invalidate_cache();\n        unsafe {\n            std::env::remove_var(vite_shared::env_vars::VITE_PLUS_HOME);\n        }\n\n        // Cache file should be removed\n        assert!(\n            std::fs::metadata(cache_file.as_path()).is_err(),\n            \"Cache file should be removed after invalidation\"\n        );\n\n        // Loading from removed file should return empty default cache\n        let loaded_cache = ResolveCache::load(&cache_file);\n        assert!(loaded_cache.get(&temp_path).is_none(), \"Cache should be empty after invalidation\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/shim/dispatch.rs",
    "content": "//! Main dispatch logic for shim operations.\n//!\n//! This module handles the core shim functionality:\n//! 1. Version resolution (with caching)\n//! 2. Node.js installation (if needed)\n//! 3. Tool execution (core tools and package binaries)\n\nuse vite_path::{AbsolutePath, AbsolutePathBuf, current_dir};\nuse vite_shared::{PrependOptions, env_vars, output, prepend_to_path_env};\n\nuse super::{\n    cache::{self, ResolveCache, ResolveCacheEntry},\n    exec, is_core_shim_tool,\n};\nuse crate::commands::env::{\n    bin_config::{BinConfig, BinSource},\n    config::{self, ShimMode},\n    global_install::CORE_SHIMS,\n    package_metadata::PackageMetadata,\n};\n\n/// Environment variable used to prevent infinite recursion in shim dispatch.\n///\n/// When set, the shim will skip version resolution and execute the tool\n/// directly using the current PATH (passthrough mode).\nconst RECURSION_ENV_VAR: &str = env_vars::VITE_PLUS_TOOL_RECURSION;\n\n/// Package manager tools that should resolve Node.js version from the project context\n/// rather than using the install-time version.\nconst PACKAGE_MANAGER_TOOLS: &[&str] = &[\"pnpm\", \"yarn\"];\n\nfn is_package_manager_tool(tool: &str) -> bool {\n    PACKAGE_MANAGER_TOOLS.contains(&tool)\n}\n\n/// Parsed npm global command (install or uninstall).\nstruct NpmGlobalCommand {\n    /// Package names/specs extracted from args (e.g., [\"codex\", \"typescript@5\"])\n    packages: Vec<String>,\n    /// Explicit `--prefix <dir>` from the CLI args, if present.\n    explicit_prefix: Option<String>,\n}\n\n/// Value-bearing npm flags whose next arg should be skipped during package extraction.\n/// Note: `--prefix` is handled separately to capture its value.\nconst NPM_VALUE_FLAGS: &[&str] = &[\"--registry\", \"--tag\", \"--cache\", \"--tmp\"];\n\n/// Install subcommands recognized by npm.\nconst NPM_INSTALL_SUBCOMMANDS: &[&str] = &[\"install\", \"i\", \"add\"];\n\n/// Uninstall subcommands recognized by npm.\nconst NPM_UNINSTALL_SUBCOMMANDS: &[&str] = &[\"uninstall\", \"un\", \"remove\", \"rm\"];\n\n/// Parse npm args to detect a global command (`npm <subcommand> -g <packages>`).\n/// Returns None if the args don't match the expected pattern.\nfn parse_npm_global_command(args: &[String], subcommands: &[&str]) -> Option<NpmGlobalCommand> {\n    let mut has_global = false;\n    let mut has_subcommand = false;\n    let mut packages = Vec::new();\n    let mut skip_next = false;\n    let mut prefix_next = false;\n    let mut explicit_prefix = None;\n    // The npm subcommand must be the first positional (non-flag) arg.\n    // Once we see a positional that isn't a recognized subcommand, no later\n    // positional can be the subcommand (e.g. `npm run install -g` → not install).\n    let mut seen_positional = false;\n\n    for arg in args {\n        // Capture the value after --prefix\n        if prefix_next {\n            prefix_next = false;\n            explicit_prefix = Some(arg.clone());\n            continue;\n        }\n\n        if skip_next {\n            skip_next = false;\n            continue;\n        }\n\n        if arg == \"-g\" || arg == \"--global\" {\n            has_global = true;\n            continue;\n        }\n\n        // Capture --prefix specially (its value is needed for prefix resolution)\n        if arg == \"--prefix\" {\n            prefix_next = true;\n            continue;\n        }\n        if let Some(value) = arg.strip_prefix(\"--prefix=\") {\n            explicit_prefix = Some(value.to_string());\n            continue;\n        }\n\n        // Check for value-bearing flags (skip their values)\n        if NPM_VALUE_FLAGS.contains(&arg.as_str()) {\n            skip_next = true;\n            continue;\n        }\n\n        // Skip flags\n        if arg.starts_with('-') {\n            continue;\n        }\n\n        // Subcommand must be the first positional arg\n        if !seen_positional && subcommands.contains(&arg.as_str()) && !has_subcommand {\n            has_subcommand = true;\n            seen_positional = true;\n            continue;\n        }\n        seen_positional = true;\n\n        // This is a positional arg (package spec)\n        packages.push(arg.clone());\n    }\n\n    if !has_global || !has_subcommand || packages.is_empty() {\n        return None;\n    }\n\n    Some(NpmGlobalCommand { packages, explicit_prefix })\n}\n\n/// Parse npm args to detect `npm install -g <packages>`.\nfn parse_npm_global_install(args: &[String]) -> Option<NpmGlobalCommand> {\n    let mut parsed = parse_npm_global_command(args, NPM_INSTALL_SUBCOMMANDS)?;\n    // Filter out URLs and git+ prefixes (too complex to resolve package names)\n    parsed.packages.retain(|pkg| !pkg.contains(\"://\") && !pkg.starts_with(\"git+\"));\n    if parsed.packages.is_empty() { None } else { Some(parsed) }\n}\n\n/// Parse npm args to detect `npm uninstall -g <packages>`.\nfn parse_npm_global_uninstall(args: &[String]) -> Option<NpmGlobalCommand> {\n    parse_npm_global_command(args, NPM_UNINSTALL_SUBCOMMANDS)\n}\n\n/// Resolve package name from a spec string.\n///\n/// Handles:\n/// - Regular specs: \"codex\" → \"codex\", \"typescript@5\" → \"typescript\"\n/// - Scoped specs: \"@scope/pkg\" → \"@scope/pkg\", \"@scope/pkg@1.0\" → \"@scope/pkg\"\n/// - Local paths: \"./foo\" → reads foo/package.json → name field\nfn is_local_path(spec: &str) -> bool {\n    spec == \".\"\n        || spec == \"..\"\n        || spec.starts_with(\"./\")\n        || spec.starts_with(\"../\")\n        || spec.starts_with('/')\n        || (cfg!(windows)\n            && spec.len() >= 3\n            && spec.as_bytes()[1] == b':'\n            && (spec.as_bytes()[2] == b'\\\\' || spec.as_bytes()[2] == b'/'))\n}\n\nfn resolve_package_name(spec: &str) -> Option<String> {\n    // Local path — read package.json to get the actual name\n    if is_local_path(spec) {\n        let pkg_json_path = current_dir().ok()?.join(spec).join(\"package.json\");\n        let content = std::fs::read_to_string(pkg_json_path.as_path()).ok()?;\n        let json: serde_json::Value = serde_json::from_str(&content).ok()?;\n        return json.get(\"name\").and_then(|n| n.as_str()).map(str::to_string);\n    }\n\n    // Scoped package: @scope/name or @scope/name@version\n    if let Some(rest) = spec.strip_prefix('@') {\n        if let Some(idx) = rest.find('@') {\n            return Some(spec[..=idx].to_string());\n        }\n        return Some(spec.to_string());\n    }\n\n    // Regular package: name or name@version\n    if let Some(idx) = spec.find('@') {\n        return Some(spec[..idx].to_string());\n    }\n\n    Some(spec.to_string())\n}\n\n/// Get the actual npm global prefix directory.\n///\n/// Runs `npm config get prefix` to determine the global prefix, which respects\n/// `NPM_CONFIG_PREFIX` env var and `.npmrc` settings. Falls back to `node_dir`.\n#[allow(clippy::disallowed_types)]\nfn get_npm_global_prefix(npm_path: &AbsolutePath, node_dir: &AbsolutePathBuf) -> AbsolutePathBuf {\n    // `npm config get prefix` respects NPM_CONFIG_PREFIX, .npmrc, and other\n    // npm config mechanisms.\n    if let Ok(output) =\n        std::process::Command::new(npm_path.as_path()).args([\"config\", \"get\", \"prefix\"]).output()\n    {\n        if output.status.success() {\n            if let Ok(prefix) = std::str::from_utf8(&output.stdout) {\n                let prefix = prefix.trim();\n                if let Some(prefix_path) = AbsolutePathBuf::new(prefix.into()) {\n                    return prefix_path;\n                }\n            }\n        }\n    }\n\n    // Fallback: default npm prefix is the Node install dir\n    node_dir.clone()\n}\n\n/// After npm install -g completes, check if installed binaries are on PATH.\n///\n/// First determines the actual npm global bin directory (which may differ from the\n/// default if the user has set a custom prefix). If that directory is already on the\n/// user's original PATH, binaries are reachable and no action is needed.\n///\n/// Otherwise, in interactive mode, prompt user to create bin links.\n/// In non-interactive mode, create links automatically.\n/// Always print a tip suggesting `vp install -g`.\n#[allow(clippy::disallowed_macros, clippy::disallowed_types)]\nfn check_npm_global_install_result(\n    packages: &[String],\n    original_path: Option<&std::ffi::OsStr>,\n    npm_prefix: &AbsolutePath,\n    node_dir: &AbsolutePath,\n    node_version: &str,\n) {\n    use std::io::IsTerminal;\n\n    let Ok(bin_dir) = config::get_bin_dir() else { return };\n\n    // Derive bin dir from prefix (Unix: prefix/bin, Windows: prefix itself)\n    #[cfg(unix)]\n    let npm_bin_dir = npm_prefix.join(\"bin\");\n    #[cfg(windows)]\n    let npm_bin_dir = npm_prefix.to_absolute_path_buf();\n\n    // If the npm global bin dir is already on the user's original PATH,\n    // binaries are reachable without shims — no action needed.\n    if let Some(orig) = original_path {\n        if std::env::split_paths(orig).any(|p| p == npm_bin_dir.as_path()) {\n            return;\n        }\n    }\n\n    let is_interactive = std::io::stdin().is_terminal();\n    // (bin_name, source_path, package_name)\n    let mut missing_bins: Vec<(String, AbsolutePathBuf, String)> = Vec::new();\n    let mut managed_conflicts: Vec<(String, String)> = Vec::new();\n\n    for spec in packages {\n        let Some(package_name) = resolve_package_name(spec) else { continue };\n        let Some(content) = read_npm_package_json(npm_prefix, node_dir, &package_name) else {\n            continue;\n        };\n        let Ok(package_json) = serde_json::from_str::<serde_json::Value>(&content) else {\n            continue;\n        };\n        let bin_names = extract_bin_names(&package_json);\n\n        for bin_name in bin_names {\n            // Skip core shims\n            if CORE_SHIMS.contains(&bin_name.as_str()) {\n                continue;\n            }\n\n            // Check if binary already exists in bin_dir (vite-plus bin)\n            // On Unix: symlinks (bin/tsc)\n            // On Windows: trampoline .exe (bin/tsc.exe) or legacy .cmd (bin/tsc.cmd)\n            let shim_path = bin_dir.join(&bin_name);\n            let shim_exists = std::fs::symlink_metadata(shim_path.as_path()).is_ok() || {\n                #[cfg(windows)]\n                {\n                    let exe_path = bin_dir.join(vite_str::format!(\"{bin_name}.exe\"));\n                    std::fs::symlink_metadata(exe_path.as_path()).is_ok()\n                }\n                #[cfg(not(windows))]\n                false\n            };\n            if shim_exists {\n                if let Ok(Some(config)) = BinConfig::load_sync(&bin_name) {\n                    if config.source == BinSource::Vp {\n                        // Managed by vp install -g — warn about the conflict\n                        managed_conflicts.push((bin_name, config.package.clone()));\n                    } else if config.source == BinSource::Npm && config.package != package_name {\n                        // Link exists from a different npm package — recreate link for new owner.\n                        // The old symlink points at the previous package's binary; we must\n                        // replace it so it resolves to the new package's binary in npm's bin dir.\n                        #[cfg(unix)]\n                        let source_path = npm_bin_dir.join(&bin_name);\n                        #[cfg(windows)]\n                        let source_path = npm_bin_dir.join(vite_str::format!(\"{bin_name}.cmd\"));\n\n                        if source_path.as_path().exists() {\n                            let _ = std::fs::remove_file(shim_path.as_path());\n                            create_bin_link(\n                                &bin_dir,\n                                &bin_name,\n                                &source_path,\n                                &package_name,\n                                node_version,\n                            );\n                        }\n                    }\n                }\n                continue;\n            }\n\n            // Also check .cmd on Windows\n            #[cfg(windows)]\n            {\n                let cmd_path = bin_dir.join(format!(\"{bin_name}.cmd\"));\n                if cmd_path.as_path().exists() {\n                    continue;\n                }\n            }\n\n            // Binary source in actual npm global bin dir\n            #[cfg(unix)]\n            let source_path = npm_bin_dir.join(&bin_name);\n            #[cfg(windows)]\n            let source_path = npm_bin_dir.join(format!(\"{bin_name}.cmd\"));\n\n            if source_path.as_path().exists() {\n                missing_bins.push((bin_name, source_path, package_name.clone()));\n            }\n        }\n    }\n\n    // Deduplicate by bin_name so that when two packages declare the same binary,\n    // only the last one is linked (matching npm's \"last writer wins\" behavior).\n    let missing_bins = dedup_missing_bins(missing_bins);\n\n    if !managed_conflicts.is_empty() {\n        for (bin_name, pkg) in &managed_conflicts {\n            output::raw(&vite_str::format!(\n                \"Skipped '{bin_name}': managed by `vp install -g {pkg}`. Run `vp uninstall -g {pkg}` to remove it first.\"\n            ));\n        }\n    }\n\n    if missing_bins.is_empty() {\n        return;\n    }\n\n    let should_link = if is_interactive {\n        // Prompt user\n        let bin_list: Vec<&str> = missing_bins.iter().map(|(name, _, _)| name.as_str()).collect();\n        let bin_display = bin_list.join(\", \");\n\n        output::raw(&vite_str::format!(\"'{bin_display}' is not available on your PATH.\"));\n        output::raw_inline(\"Create a link in ~/.vite-plus/bin/ to make it available? [Y/n] \");\n        let _ = std::io::Write::flush(&mut std::io::stdout());\n\n        let mut input = String::new();\n        let confirmed = std::io::stdin().read_line(&mut input).is_ok();\n        let trimmed = input.trim();\n        confirmed\n            && (trimmed.is_empty()\n                || trimmed.eq_ignore_ascii_case(\"y\")\n                || trimmed.eq_ignore_ascii_case(\"yes\"))\n    } else {\n        // Non-interactive: auto-link\n        true\n    };\n\n    if should_link {\n        for (bin_name, source_path, package_name) in &missing_bins {\n            create_bin_link(&bin_dir, bin_name, source_path, package_name, node_version);\n        }\n    }\n\n    // Always print the tip\n    let pkg_names: Vec<&str> = packages.iter().map(String::as_str).collect();\n    let pkg_display = pkg_names.join(\" \");\n    output::raw(&vite_str::format!(\n        \"\\ntip: Use `vp install -g {pkg_display}` for managed shims that persist across Node.js version changes.\"\n    ));\n}\n\n/// Extract binary names from a package.json value.\nfn extract_bin_names(package_json: &serde_json::Value) -> Vec<String> {\n    let mut bins = Vec::new();\n\n    if let Some(bin) = package_json.get(\"bin\") {\n        match bin {\n            serde_json::Value::String(_) => {\n                // Single binary with package name\n                if let Some(name) = package_json[\"name\"].as_str() {\n                    let bin_name = name.split('/').last().unwrap_or(name);\n                    bins.push(bin_name.to_string());\n                }\n            }\n            serde_json::Value::Object(map) => {\n                for name in map.keys() {\n                    bins.push(name.clone());\n                }\n            }\n            _ => {}\n        }\n    }\n\n    bins\n}\n\n/// Extract the relative path for a specific bin name from a package.json \"bin\" field.\nfn extract_bin_path(package_json: &serde_json::Value, bin_name: &str) -> Option<String> {\n    match package_json.get(\"bin\")? {\n        serde_json::Value::String(path) => {\n            // Single binary — matches if the package name's last segment equals bin_name\n            let pkg_name = package_json[\"name\"].as_str()?;\n            let expected = pkg_name.split('/').last().unwrap_or(pkg_name);\n            if expected == bin_name { Some(path.clone()) } else { None }\n        }\n        serde_json::Value::Object(map) => {\n            map.get(bin_name).and_then(|v| v.as_str()).map(str::to_string)\n        }\n        _ => None,\n    }\n}\n\n/// Create a bin link for a binary and record it via BinConfig.\nfn create_bin_link(\n    bin_dir: &AbsolutePath,\n    bin_name: &str,\n    source_path: &AbsolutePath,\n    package_name: &str,\n    node_version: &str,\n) {\n    let mut linked = false;\n\n    #[cfg(unix)]\n    {\n        let link_path = bin_dir.join(bin_name);\n        if std::os::unix::fs::symlink(source_path.as_path(), link_path.as_path()).is_ok() {\n            output::raw(&vite_str::format!(\n                \"Linked '{bin_name}' to {}\",\n                link_path.as_path().display()\n            ));\n            linked = true;\n        } else {\n            output::error(&vite_str::format!(\"Failed to create link for '{bin_name}'\"));\n        }\n    }\n\n    #[cfg(windows)]\n    {\n        // npm-installed packages use .cmd wrappers pointing to npm's generated script.\n        // Unlike vp-installed packages, these don't have PackageMetadata, so the\n        // trampoline approach won't work (dispatch_package_binary would fail).\n        let cmd_path = bin_dir.join(vite_str::format!(\"{bin_name}.cmd\"));\n        let wrapper_content = vite_str::format!(\n            \"@echo off\\r\\n\\\"{source}\\\" %*\\r\\nexit /b %ERRORLEVEL%\\r\\n\",\n            source = source_path.as_path().display()\n        );\n        if std::fs::write(cmd_path.as_path(), &*wrapper_content).is_ok() {\n            output::raw(&vite_str::format!(\n                \"Linked '{bin_name}' to {}\",\n                cmd_path.as_path().display()\n            ));\n            linked = true;\n        } else {\n            output::error(&vite_str::format!(\"Failed to create link for '{bin_name}'\"));\n        }\n\n        // Also create shell script for Git Bash\n        let sh_path = bin_dir.join(bin_name);\n        let sh_content =\n            format!(\"#!/bin/sh\\nexec \\\"{}\\\" \\\"$@\\\"\\n\", source_path.as_path().display());\n        let _ = std::fs::write(sh_path.as_path(), sh_content);\n    }\n\n    // Record the link in BinConfig so we can identify it during uninstall\n    if linked {\n        let _ = BinConfig::new_npm(\n            bin_name.to_string(),\n            package_name.to_string(),\n            node_version.to_string(),\n        )\n        .save_sync();\n    }\n}\n\n/// Deduplicate missing_bins by bin_name, keeping the last entry (npm's \"last writer wins\").\n///\n/// When `npm install -g pkg-a pkg-b` and both declare the same binary name, we get\n/// duplicate entries. Without dedup, `create_bin_link` would fail on the second entry\n/// because the symlink already exists, leaving stale BinConfig for the first package.\n#[allow(clippy::disallowed_types)]\nfn dedup_missing_bins(\n    missing_bins: Vec<(String, AbsolutePathBuf, String)>,\n) -> Vec<(String, AbsolutePathBuf, String)> {\n    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();\n    let mut deduped = Vec::new();\n    for entry in missing_bins.into_iter().rev() {\n        if seen.insert(entry.0.clone()) {\n            deduped.push(entry);\n        }\n    }\n    deduped.reverse();\n    deduped\n}\n\n/// After npm uninstall -g completes, remove bin links that were created during install.\n///\n/// Each entry is `(bin_name, package_name)`. We only remove a link if its BinConfig\n/// has `source: Npm` AND `package` matches the package being uninstalled. This prevents\n/// removing a link that was overwritten by a later install of a different package.\n///\n/// When a bin is owned by a **different** npm package (not being uninstalled), npm may\n/// still delete its binary from `npm_bin_dir`, leaving our symlink dangling. In that\n/// case we repair the link by pointing directly at the surviving package's binary.\n#[allow(clippy::disallowed_types)]\nfn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefix: &AbsolutePath) {\n    let Ok(bin_dir) = config::get_bin_dir() else { return };\n\n    for (bin_name, package_name) in bin_entries {\n        // Skip core shims\n        if CORE_SHIMS.contains(&bin_name.as_str()) {\n            continue;\n        }\n\n        let config = match BinConfig::load_sync(bin_name) {\n            Ok(Some(c)) if c.source == BinSource::Npm => c,\n            _ => continue,\n        };\n\n        if config.package == *package_name {\n            // Owned by the package being uninstalled — remove the link\n            let link_path = bin_dir.join(bin_name);\n            if std::fs::symlink_metadata(link_path.as_path()).is_ok() {\n                if std::fs::remove_file(link_path.as_path()).is_ok() {\n                    output::raw(&vite_str::format!(\n                        \"Removed link '{bin_name}' from {}\",\n                        link_path.as_path().display()\n                    ));\n                }\n            }\n\n            // Clean up the BinConfig\n            let _ = BinConfig::delete_sync(bin_name);\n\n            // Also remove .cmd and .exe on Windows\n            #[cfg(windows)]\n            {\n                let cmd_path = bin_dir.join(vite_str::format!(\"{bin_name}.cmd\"));\n                let _ = std::fs::remove_file(cmd_path.as_path());\n                let exe_path = bin_dir.join(vite_str::format!(\"{bin_name}.exe\"));\n                let _ = std::fs::remove_file(exe_path.as_path());\n            }\n        } else {\n            // Owned by a different npm package — check if our link target is now broken\n            // (npm may have deleted the binary from npm_bin_dir when uninstalling)\n            let link_path = bin_dir.join(bin_name);\n\n            // On Unix, exists() follows the symlink — if target is gone, it returns false.\n            // On Windows, the shim files are regular files that always \"exist\",\n            // so we always fall through to the repair check below.\n            #[cfg(unix)]\n            if link_path.as_path().exists() {\n                // Target still accessible — nothing to repair\n                continue;\n            }\n\n            // Target is broken — repair by pointing to the surviving package's binary\n            let surviving_pkg = &config.package;\n            let node_modules_dir = config::get_node_modules_dir(npm_prefix, surviving_pkg);\n            let pkg_json_path = node_modules_dir.join(\"package.json\");\n            let content = match std::fs::read_to_string(pkg_json_path.as_path()) {\n                Ok(c) => c,\n                Err(_) => continue,\n            };\n            let package_json = match serde_json::from_str::<serde_json::Value>(&content) {\n                Ok(v) => v,\n                Err(_) => continue,\n            };\n            let Some(bin_rel_path) = extract_bin_path(&package_json, bin_name) else {\n                continue;\n            };\n            let source_path = node_modules_dir.join(&bin_rel_path);\n            if source_path.as_path().exists() {\n                let _ = std::fs::remove_file(link_path.as_path());\n                #[cfg(windows)]\n                {\n                    let cmd_path = bin_dir.join(vite_str::format!(\"{bin_name}.cmd\"));\n                    let _ = std::fs::remove_file(cmd_path.as_path());\n                }\n                create_bin_link(\n                    &bin_dir,\n                    bin_name,\n                    &source_path,\n                    surviving_pkg,\n                    &config.node_version,\n                );\n            }\n        }\n    }\n}\n\n/// Read the installed package.json from npm's node_modules directory.\n/// Tries the npm prefix first (handles custom prefix), then falls back to node_dir.\n#[allow(clippy::disallowed_types)]\nfn read_npm_package_json(\n    npm_prefix: &AbsolutePath,\n    node_dir: &AbsolutePath,\n    package_name: &str,\n) -> Option<String> {\n    std::fs::read_to_string(\n        config::get_node_modules_dir(npm_prefix, package_name).join(\"package.json\").as_path(),\n    )\n    .ok()\n    .or_else(|| {\n        let dir = config::get_node_modules_dir(node_dir, package_name);\n        std::fs::read_to_string(dir.join(\"package.json\").as_path()).ok()\n    })\n}\n\n/// Collect (bin_name, package_name) pairs from packages by reading their installed package.json files.\n#[allow(clippy::disallowed_types)]\nfn collect_bin_names_from_npm(\n    packages: &[String],\n    npm_prefix: &AbsolutePath,\n    node_dir: &AbsolutePath,\n) -> Vec<(String, String)> {\n    let mut all_bins = Vec::new();\n\n    for spec in packages {\n        let Some(package_name) = resolve_package_name(spec) else { continue };\n        let Some(content) = read_npm_package_json(npm_prefix, node_dir, &package_name) else {\n            continue;\n        };\n        let Ok(package_json) = serde_json::from_str::<serde_json::Value>(&content) else {\n            continue;\n        };\n        for bin_name in extract_bin_names(&package_json) {\n            all_bins.push((bin_name, package_name.clone()));\n        }\n    }\n\n    all_bins\n}\n\n/// Resolve the npm prefix, preferring an explicit `--prefix` from CLI args.\n///\n/// Handles both absolute and relative `--prefix` values by resolving against cwd.\n/// `AbsolutePathBuf::join` replaces the base when the argument is absolute (like\n/// `PathBuf::join`), so `cwd.join(\"/abs\")` → `/abs` and `cwd.join(\"./rel\")` → `/cwd/./rel`.\nfn resolve_npm_prefix(\n    parsed: &NpmGlobalCommand,\n    npm_path: &AbsolutePath,\n    node_dir: &AbsolutePathBuf,\n) -> AbsolutePathBuf {\n    if let Some(ref prefix) = parsed.explicit_prefix {\n        if let Ok(cwd) = current_dir() {\n            return cwd.join(prefix);\n        }\n    }\n    get_npm_global_prefix(npm_path, node_dir)\n}\n\n/// Main shim dispatch entry point.\n///\n/// Called when the binary is invoked as node, npm, npx, or a package binary.\n/// Returns an exit code to be used with std::process::exit.\npub async fn dispatch(tool: &str, args: &[String]) -> i32 {\n    tracing::debug!(\"dispatch: tool: {tool}, args: {:?}\", args);\n\n    // Handle vpx — standalone command, doesn't need recursion/bypass/shim-mode checks\n    if tool == \"vpx\" {\n        let cwd = match current_dir() {\n            Ok(path) => path,\n            Err(e) => {\n                eprintln!(\"vp: Failed to get current directory: {e}\");\n                return 1;\n            }\n        };\n        return crate::commands::vpx::execute_vpx(args, &cwd).await;\n    }\n\n    // Check recursion prevention - if already in a shim context, passthrough directly\n    // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH.\n    // Package binaries are always resolved via metadata lookup, so they can't loop.\n    if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) {\n        tracing::debug!(\"recursion prevention enabled for core tool\");\n        return passthrough_to_system(tool, args);\n    }\n\n    // Check bypass mode (explicit environment variable)\n    if std::env::var(env_vars::VITE_PLUS_BYPASS).is_ok() {\n        tracing::debug!(\"bypass mode enabled\");\n        return bypass_to_system(tool, args);\n    }\n\n    // Check shim mode from config\n    let shim_mode = load_shim_mode().await;\n    if shim_mode == ShimMode::SystemFirst {\n        tracing::debug!(\"system-first mode enabled\");\n        // In system-first mode, try to find system tool first\n        if let Some(system_path) = find_system_tool(tool) {\n            // Append current bin_dir to VITE_PLUS_BYPASS to prevent infinite loops\n            // when multiple vite-plus installations exist in PATH.\n            // The next installation will filter all accumulated paths.\n            if let Ok(bin_dir) = config::get_bin_dir() {\n                let bypass_val = match std::env::var_os(env_vars::VITE_PLUS_BYPASS) {\n                    Some(existing) => {\n                        let mut paths: Vec<_> = std::env::split_paths(&existing).collect();\n                        paths.push(bin_dir.as_path().to_path_buf());\n                        std::env::join_paths(paths).unwrap_or(existing)\n                    }\n                    None => std::ffi::OsString::from(bin_dir.as_path()),\n                };\n                // SAFETY: Setting env vars before exec (which replaces the process) is safe\n                unsafe {\n                    std::env::set_var(env_vars::VITE_PLUS_BYPASS, bypass_val);\n                }\n            }\n            return exec::exec_tool(&system_path, args);\n        }\n        // Fall through to managed if system not found\n    }\n\n    // Check if this is a package binary (not node/npm/npx)\n    if !is_core_shim_tool(tool) {\n        return dispatch_package_binary(tool, args).await;\n    }\n\n    // Get current working directory\n    let cwd = match current_dir() {\n        Ok(path) => path,\n        Err(e) => {\n            eprintln!(\"vp: Failed to get current directory: {e}\");\n            return 1;\n        }\n    };\n\n    // Resolve version (with caching)\n    let resolution = match resolve_with_cache(&cwd).await {\n        Ok(r) => r,\n        Err(e) => {\n            eprintln!(\"vp: Failed to resolve Node version: {e}\");\n            eprintln!(\"vp: Run 'vp env doctor' for diagnostics\");\n            return 1;\n        }\n    };\n\n    // Ensure Node.js is installed\n    if let Err(e) = ensure_installed(&resolution.version).await {\n        eprintln!(\"vp: Failed to install Node {}: {e}\", resolution.version);\n        return 1;\n    }\n\n    // Locate tool binary\n    let tool_path = match locate_tool(&resolution.version, tool) {\n        Ok(p) => p,\n        Err(e) => {\n            eprintln!(\"vp: Tool '{tool}' not found: {e}\");\n            return 1;\n        }\n    };\n\n    // Save original PATH before we modify it — needed for npm global install check.\n    // Only captured for npm to avoid unnecessary work on node/npx hot path.\n    let original_path = if tool == \"npm\" { std::env::var_os(\"PATH\") } else { None };\n\n    // Prepare environment for recursive invocations\n    // Prepend real node bin dir to PATH so child processes use the correct version\n    let node_bin_dir = tool_path.parent().expect(\"Tool has no parent directory\");\n    // Use dedupe_anywhere=false to only check if it's first in PATH (original behavior)\n    prepend_to_path_env(node_bin_dir, PrependOptions::default());\n\n    // Optional debug env vars\n    if std::env::var(env_vars::VITE_PLUS_DEBUG_SHIM).is_ok() {\n        // SAFETY: Setting env vars at this point before exec is safe\n        unsafe {\n            std::env::set_var(env_vars::VITE_PLUS_ACTIVE_NODE, &resolution.version);\n            std::env::set_var(env_vars::VITE_PLUS_RESOLVE_SOURCE, &resolution.source);\n        }\n    }\n\n    // Set recursion prevention marker before executing\n    // This prevents infinite loops when the executed tool invokes another shim\n    // SAFETY: Setting env vars at this point before exec is safe\n    unsafe {\n        std::env::set_var(RECURSION_ENV_VAR, \"1\");\n    }\n\n    // For npm install/uninstall -g, use spawn+wait so we can post-check/cleanup binaries\n    if tool == \"npm\" {\n        if let Some(parsed) = parse_npm_global_install(args) {\n            let exit_code = exec::spawn_tool(&tool_path, args);\n            if exit_code == 0 {\n                if let Ok(home_dir) = vite_shared::get_vite_plus_home() {\n                    let node_dir =\n                        home_dir.join(\"js_runtime\").join(\"node\").join(&*resolution.version);\n                    let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir);\n                    check_npm_global_install_result(\n                        &parsed.packages,\n                        original_path.as_deref(),\n                        &npm_prefix,\n                        &node_dir,\n                        &resolution.version,\n                    );\n                }\n            }\n            return exit_code;\n        }\n\n        if let Some(parsed) = parse_npm_global_uninstall(args) {\n            // Collect bin names before uninstall (package.json will be gone after)\n            let context = if let Ok(home_dir) = vite_shared::get_vite_plus_home() {\n                let node_dir = home_dir.join(\"js_runtime\").join(\"node\").join(&*resolution.version);\n                let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir);\n                let bins = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir);\n                Some((bins, npm_prefix))\n            } else {\n                None\n            };\n            let exit_code = exec::spawn_tool(&tool_path, args);\n            if exit_code == 0 {\n                if let Some((bin_names, npm_prefix)) = context {\n                    remove_npm_global_uninstall_links(&bin_names, &npm_prefix);\n                }\n            }\n            return exit_code;\n        }\n    }\n\n    // Execute the tool (normal path — exec replaces process on Unix)\n    exec::exec_tool(&tool_path, args)\n}\n\n/// Dispatch a package binary shim.\n///\n/// Finds the package that provides this binary and executes it with the\n/// Node.js version that was used to install the package.\nasync fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 {\n    // Find which package provides this binary\n    let package_metadata = match find_package_for_binary(tool).await {\n        Ok(Some(metadata)) => metadata,\n        Ok(None) => {\n            eprintln!(\"vp: Binary '{tool}' not found in any installed package\");\n            eprintln!(\"vp: Run 'vp install -g <package>' to install\");\n            return 1;\n        }\n        Err(e) => {\n            eprintln!(\"vp: Failed to find package for '{tool}': {e}\");\n            return 1;\n        }\n    };\n\n    // Determine Node.js version to use:\n    // - Package managers (pnpm, yarn): resolve from project context so they respect\n    //   the project's engines.node / .node-version, falling back to install-time version\n    // - Other package binaries: use the install-time version (original behavior)\n    let node_version = if is_package_manager_tool(tool) {\n        let cwd = match current_dir() {\n            Ok(path) => path,\n            Err(e) => {\n                eprintln!(\"vp: Failed to get current directory: {e}\");\n                return 1;\n            }\n        };\n        match resolve_with_cache(&cwd).await {\n            Ok(resolution) => resolution.version,\n            Err(_) => {\n                // Fall back to install-time version if project resolution fails\n                package_metadata.platform.node.clone()\n            }\n        }\n    } else {\n        package_metadata.platform.node.clone()\n    };\n\n    // Ensure Node.js is installed\n    if let Err(e) = ensure_installed(&node_version).await {\n        eprintln!(\"vp: Failed to install Node {}: {e}\", node_version);\n        return 1;\n    }\n\n    // Locate the actual binary in the package directory\n    let binary_path = match locate_package_binary(&package_metadata.name, tool) {\n        Ok(p) => p,\n        Err(e) => {\n            eprintln!(\"vp: Binary '{tool}' not found: {e}\");\n            return 1;\n        }\n    };\n\n    // Locate node binary for this version\n    let node_path = match locate_tool(&node_version, \"node\") {\n        Ok(p) => p,\n        Err(e) => {\n            eprintln!(\"vp: Node not found: {e}\");\n            return 1;\n        }\n    };\n\n    // Prepare environment for recursive invocations\n    let node_bin_dir = node_path.parent().expect(\"Node has no parent directory\");\n    prepend_to_path_env(node_bin_dir, PrependOptions::default());\n\n    // Check if the binary is a JavaScript file that needs Node.js\n    // This info was determined at install time and stored in metadata\n    if package_metadata.is_js_binary(tool) {\n        // Execute: node <binary_path> <args>\n        let mut full_args = vec![binary_path.as_path().display().to_string()];\n        full_args.extend(args.iter().cloned());\n        exec::exec_tool(&node_path, &full_args)\n    } else {\n        // Execute the binary directly (native executable or non-Node script)\n        exec::exec_tool(&binary_path, args)\n    }\n}\n\n/// Find the package that provides a given binary.\n///\n/// Uses BinConfig for deterministic O(1) lookup instead of scanning all packages.\npub(crate) async fn find_package_for_binary(\n    binary_name: &str,\n) -> Result<Option<PackageMetadata>, String> {\n    // Use BinConfig for deterministic lookup\n    if let Some(bin_config) = BinConfig::load(binary_name).await.map_err(|e| format!(\"{e}\"))? {\n        return PackageMetadata::load(&bin_config.package).await.map_err(|e| format!(\"{e}\"));\n    }\n\n    // Binary not installed\n    Ok(None)\n}\n\n/// Locate a binary within a package's installation directory.\npub(crate) fn locate_package_binary(\n    package_name: &str,\n    binary_name: &str,\n) -> Result<AbsolutePathBuf, String> {\n    let packages_dir = config::get_packages_dir().map_err(|e| format!(\"{e}\"))?;\n    let package_dir = packages_dir.join(package_name);\n\n    // The binary is referenced in package.json's bin field\n    // npm uses different layouts: Unix=lib/node_modules, Windows=node_modules\n    let node_modules_dir = config::get_node_modules_dir(&package_dir, package_name);\n    let package_json_path = node_modules_dir.join(\"package.json\");\n\n    if !package_json_path.as_path().exists() {\n        return Err(format!(\"Package {} not found\", package_name));\n    }\n\n    // Read package.json to find the binary path\n    let content = std::fs::read_to_string(package_json_path.as_path())\n        .map_err(|e| format!(\"Failed to read package.json: {e}\"))?;\n    let package_json: serde_json::Value =\n        serde_json::from_str(&content).map_err(|e| format!(\"Failed to parse package.json: {e}\"))?;\n\n    let binary_path = match package_json.get(\"bin\") {\n        Some(serde_json::Value::String(path)) => {\n            // Single binary - check if it matches the name\n            let pkg_name = package_json[\"name\"].as_str().unwrap_or(\"\");\n            let expected_name = pkg_name.split('/').last().unwrap_or(pkg_name);\n            if expected_name == binary_name {\n                node_modules_dir.join(path)\n            } else {\n                return Err(format!(\"Binary {} not found in package\", binary_name));\n            }\n        }\n        Some(serde_json::Value::Object(map)) => {\n            // Multiple binaries - find the one we need\n            if let Some(serde_json::Value::String(path)) = map.get(binary_name) {\n                node_modules_dir.join(path)\n            } else {\n                return Err(format!(\"Binary {} not found in package\", binary_name));\n            }\n        }\n        _ => {\n            return Err(format!(\"No bin field in package.json for {}\", package_name));\n        }\n    };\n\n    if !binary_path.as_path().exists() {\n        return Err(format!(\n            \"Binary {} not found at {}\",\n            binary_name,\n            binary_path.as_path().display()\n        ));\n    }\n\n    Ok(binary_path)\n}\n\n/// Bypass shim and use system tool.\nfn bypass_to_system(tool: &str, args: &[String]) -> i32 {\n    match find_system_tool(tool) {\n        Some(system_path) => exec::exec_tool(&system_path, args),\n        None => {\n            eprintln!(\"vp: VITE_PLUS_BYPASS is set but no system '{tool}' found in PATH\");\n            1\n        }\n    }\n}\n\n/// Passthrough mode for recursion prevention.\n///\n/// When VITE_PLUS_TOOL_RECURSION is set, we skip version resolution\n/// and execute the tool directly using the current PATH.\n/// This prevents infinite loops when a managed tool invokes another shim.\nfn passthrough_to_system(tool: &str, args: &[String]) -> i32 {\n    match find_system_tool(tool) {\n        Some(system_path) => exec::exec_tool(&system_path, args),\n        None => {\n            eprintln!(\"vp: Recursion detected but no '{tool}' found in PATH (excluding shims)\");\n            1\n        }\n    }\n}\n\n/// Resolve version with caching.\nasync fn resolve_with_cache(cwd: &AbsolutePathBuf) -> Result<ResolveCacheEntry, String> {\n    // Fast-path: VITE_PLUS_NODE_VERSION env var set by `vp env use`\n    // Skip all disk I/O for cache when session override is active\n    if let Ok(env_version) = std::env::var(config::VERSION_ENV_VAR) {\n        let env_version = env_version.trim().to_string();\n        if !env_version.is_empty() {\n            return Ok(ResolveCacheEntry {\n                version: env_version,\n                source: config::VERSION_ENV_VAR.to_string(),\n                project_root: None,\n                resolved_at: cache::now_timestamp(),\n                version_file_mtime: 0,\n                source_path: None,\n                is_range: false,\n            });\n        }\n    }\n\n    // Fast-path: session version file written by `vp env use`\n    if let Some(session_version) = config::read_session_version().await {\n        return Ok(ResolveCacheEntry {\n            version: session_version,\n            source: config::SESSION_VERSION_FILE.to_string(),\n            project_root: None,\n            resolved_at: cache::now_timestamp(),\n            version_file_mtime: 0,\n            source_path: None,\n            is_range: false,\n        });\n    }\n\n    // Load cache\n    let cache_path = cache::get_cache_path();\n    let mut cache = cache_path.as_ref().map(|p| ResolveCache::load(p)).unwrap_or_default();\n\n    // Check cache hit\n    if let Some(entry) = cache.get(cwd) {\n        tracing::debug!(\n            \"Cache hit for {}: {} (from {})\",\n            cwd.as_path().display(),\n            entry.version,\n            entry.source\n        );\n        return Ok(entry.clone());\n    }\n\n    // Cache miss - resolve version\n    let resolution = config::resolve_version(cwd).await.map_err(|e| format!(\"{e}\"))?;\n\n    // Create cache entry\n    let mtime = resolution.source_path.as_ref().and_then(|p| cache::get_file_mtime(p)).unwrap_or(0);\n\n    let entry = ResolveCacheEntry {\n        version: resolution.version.clone(),\n        source: resolution.source.clone(),\n        project_root: resolution\n            .project_root\n            .as_ref()\n            .map(|p: &AbsolutePathBuf| p.as_path().display().to_string()),\n        resolved_at: cache::now_timestamp(),\n        version_file_mtime: mtime,\n        source_path: resolution\n            .source_path\n            .as_ref()\n            .map(|p: &AbsolutePathBuf| p.as_path().display().to_string()),\n        is_range: resolution.is_range,\n    };\n\n    // Save to cache\n    cache.insert(cwd, entry.clone());\n    if let Some(ref path) = cache_path {\n        cache.save(path);\n    }\n\n    Ok(entry)\n}\n\n/// Ensure Node.js is installed.\npub(crate) async fn ensure_installed(version: &str) -> Result<(), String> {\n    let home_dir = vite_shared::get_vite_plus_home()\n        .map_err(|e| format!(\"Failed to get vite-plus home dir: {e}\"))?\n        .join(\"js_runtime\")\n        .join(\"node\")\n        .join(version);\n\n    #[cfg(windows)]\n    let binary_path = home_dir.join(\"node.exe\");\n    #[cfg(not(windows))]\n    let binary_path = home_dir.join(\"bin\").join(\"node\");\n\n    // Check if already installed\n    if binary_path.as_path().exists() {\n        return Ok(());\n    }\n\n    // Download the runtime\n    vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, version)\n        .await\n        .map_err(|e| format!(\"{e}\"))?;\n    Ok(())\n}\n\n/// Locate a tool binary within the Node.js installation.\npub(crate) fn locate_tool(version: &str, tool: &str) -> Result<AbsolutePathBuf, String> {\n    let home_dir = vite_shared::get_vite_plus_home()\n        .map_err(|e| format!(\"Failed to get vite-plus home dir: {e}\"))?\n        .join(\"js_runtime\")\n        .join(\"node\")\n        .join(version);\n\n    #[cfg(windows)]\n    let tool_path = if tool == \"node\" {\n        home_dir.join(\"node.exe\")\n    } else {\n        // npm and npx are .cmd scripts on Windows\n        home_dir.join(format!(\"{tool}.cmd\"))\n    };\n\n    #[cfg(not(windows))]\n    let tool_path = home_dir.join(\"bin\").join(tool);\n\n    if !tool_path.as_path().exists() {\n        return Err(format!(\"Tool '{}' not found at {}\", tool, tool_path.as_path().display()));\n    }\n\n    Ok(tool_path)\n}\n\n/// Load shim mode from config.\n///\n/// Returns the default (Managed) if config cannot be read.\nasync fn load_shim_mode() -> ShimMode {\n    config::load_config().await.map(|c| c.shim_mode).unwrap_or_default()\n}\n\n/// Find a system tool in PATH, skipping the vite-plus bin directory and any\n/// directories listed in `VITE_PLUS_BYPASS`.\n///\n/// Returns the absolute path to the tool if found, None otherwise.\nfn find_system_tool(tool: &str) -> Option<AbsolutePathBuf> {\n    let bin_dir = config::get_bin_dir().ok();\n    let path_var = std::env::var_os(\"PATH\")?;\n    tracing::debug!(\"path_var: {:?}\", path_var);\n\n    // Parse VITE_PLUS_BYPASS as a PATH-style list of additional directories to skip.\n    // This prevents infinite loops when multiple vite-plus installations exist in PATH.\n    let bypass_paths: Vec<std::path::PathBuf> = std::env::var_os(env_vars::VITE_PLUS_BYPASS)\n        .map(|v| std::env::split_paths(&v).collect())\n        .unwrap_or_default();\n    tracing::debug!(\"bypass_paths: {:?}\", bypass_paths);\n\n    // Filter PATH to exclude our bin directory and any bypass directories\n    let filtered_paths: Vec<_> = std::env::split_paths(&path_var)\n        .filter(|p| {\n            if let Some(ref bin) = bin_dir {\n                if p == bin.as_path() {\n                    return false;\n                }\n            }\n            !bypass_paths.iter().any(|bp| p == bp)\n        })\n        .collect();\n\n    let filtered_path = std::env::join_paths(filtered_paths).ok()?;\n\n    // Use vite_command::resolve_bin with filtered PATH - stops at first match\n    let cwd = current_dir().ok()?;\n    vite_command::resolve_bin(tool, Some(&filtered_path), &cwd).ok()\n}\n\n#[cfg(test)]\nmod tests {\n    use serial_test::serial;\n    use tempfile::TempDir;\n\n    use super::*;\n\n    /// Create a fake executable file in the given directory.\n    #[cfg(unix)]\n    fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf {\n        use std::os::unix::fs::PermissionsExt;\n        let path = dir.join(name);\n        std::fs::write(&path, \"#!/bin/sh\\n\").unwrap();\n        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();\n        path\n    }\n\n    #[cfg(windows)]\n    fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf {\n        let path = dir.join(format!(\"{name}.exe\"));\n        std::fs::write(&path, \"fake\").unwrap();\n        path\n    }\n\n    /// Helper to save and restore PATH and VITE_PLUS_BYPASS around a test.\n    struct EnvGuard {\n        original_path: Option<std::ffi::OsString>,\n        original_bypass: Option<std::ffi::OsString>,\n    }\n\n    impl EnvGuard {\n        fn new() -> Self {\n            Self {\n                original_path: std::env::var_os(\"PATH\"),\n                original_bypass: std::env::var_os(env_vars::VITE_PLUS_BYPASS),\n            }\n        }\n    }\n\n    impl Drop for EnvGuard {\n        fn drop(&mut self) {\n            unsafe {\n                match &self.original_path {\n                    Some(v) => std::env::set_var(\"PATH\", v),\n                    None => std::env::remove_var(\"PATH\"),\n                }\n                match &self.original_bypass {\n                    Some(v) => std::env::set_var(env_vars::VITE_PLUS_BYPASS, v),\n                    None => std::env::remove_var(env_vars::VITE_PLUS_BYPASS),\n                }\n            }\n        }\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_system_tool_works_without_bypass() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let dir = temp.path().join(\"bin_a\");\n        std::fs::create_dir_all(&dir).unwrap();\n        create_fake_executable(&dir, \"mytesttool\");\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", &dir);\n            std::env::remove_var(env_vars::VITE_PLUS_BYPASS);\n        }\n\n        let result = find_system_tool(\"mytesttool\");\n        assert!(result.is_some(), \"Should find tool when no bypass is set\");\n        assert!(result.unwrap().as_path().starts_with(&dir));\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_system_tool_skips_single_bypass_path() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let dir_a = temp.path().join(\"bin_a\");\n        let dir_b = temp.path().join(\"bin_b\");\n        std::fs::create_dir_all(&dir_a).unwrap();\n        std::fs::create_dir_all(&dir_b).unwrap();\n        create_fake_executable(&dir_a, \"mytesttool\");\n        create_fake_executable(&dir_b, \"mytesttool\");\n\n        let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap();\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", &path);\n            // Bypass dir_a — should skip it and find dir_b's tool\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, dir_a.as_os_str());\n        }\n\n        let result = find_system_tool(\"mytesttool\");\n        assert!(result.is_some(), \"Should find tool in non-bypassed directory\");\n        assert!(\n            result.unwrap().as_path().starts_with(&dir_b),\n            \"Should find tool in dir_b, not dir_a\"\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_system_tool_filters_multiple_bypass_paths() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let dir_a = temp.path().join(\"bin_a\");\n        let dir_b = temp.path().join(\"bin_b\");\n        let dir_c = temp.path().join(\"bin_c\");\n        std::fs::create_dir_all(&dir_a).unwrap();\n        std::fs::create_dir_all(&dir_b).unwrap();\n        std::fs::create_dir_all(&dir_c).unwrap();\n        create_fake_executable(&dir_a, \"mytesttool\");\n        create_fake_executable(&dir_b, \"mytesttool\");\n        create_fake_executable(&dir_c, \"mytesttool\");\n\n        let path =\n            std::env::join_paths([dir_a.as_path(), dir_b.as_path(), dir_c.as_path()]).unwrap();\n        let bypass = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap();\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", &path);\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, &bypass);\n        }\n\n        let result = find_system_tool(\"mytesttool\");\n        assert!(result.is_some(), \"Should find tool in dir_c\");\n        assert!(\n            result.unwrap().as_path().starts_with(&dir_c),\n            \"Should find tool in dir_c since dir_a and dir_b are bypassed\"\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn test_find_system_tool_returns_none_when_all_paths_bypassed() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let dir_a = temp.path().join(\"bin_a\");\n        std::fs::create_dir_all(&dir_a).unwrap();\n        create_fake_executable(&dir_a, \"mytesttool\");\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", dir_a.as_os_str());\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, dir_a.as_os_str());\n        }\n\n        let result = find_system_tool(\"mytesttool\");\n        assert!(result.is_none(), \"Should return None when all paths are bypassed\");\n    }\n\n    /// Simulates the SystemFirst loop prevention: Installation A sets VITE_PLUS_BYPASS\n    /// with its own bin dir, then Installation B (seeing VITE_PLUS_BYPASS) should filter\n    /// both A's dir (from bypass) and its own dir (from get_bin_dir), finding the real tool\n    /// in a third directory or returning None.\n    #[test]\n    #[serial]\n    fn test_find_system_tool_cumulative_bypass_prevents_loop() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let install_a_bin = temp.path().join(\"install_a_bin\");\n        let install_b_bin = temp.path().join(\"install_b_bin\");\n        let real_system_bin = temp.path().join(\"real_system\");\n        std::fs::create_dir_all(&install_a_bin).unwrap();\n        std::fs::create_dir_all(&install_b_bin).unwrap();\n        std::fs::create_dir_all(&real_system_bin).unwrap();\n        create_fake_executable(&install_a_bin, \"mytesttool\");\n        create_fake_executable(&install_b_bin, \"mytesttool\");\n        create_fake_executable(&real_system_bin, \"mytesttool\");\n\n        // PATH has all three dirs: install_a, install_b, real_system\n        let path = std::env::join_paths([\n            install_a_bin.as_path(),\n            install_b_bin.as_path(),\n            real_system_bin.as_path(),\n        ])\n        .unwrap();\n\n        // Simulate: Installation A already set VITE_PLUS_BYPASS=<install_a_bin>\n        // Installation B also needs to filter install_b_bin (via get_bin_dir),\n        // but get_bin_dir returns the real vite-plus home. So we test by putting\n        // install_b_bin in the bypass as well (simulating cumulative append).\n        let bypass =\n            std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap();\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", &path);\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, &bypass);\n        }\n\n        let result = find_system_tool(\"mytesttool\");\n        assert!(result.is_some(), \"Should find tool in real_system directory\");\n        assert!(\n            result.unwrap().as_path().starts_with(&real_system_bin),\n            \"Should find the real system tool, not any vite-plus installation\"\n        );\n    }\n\n    /// When both installations are bypassed and no real system tool exists, should return None.\n    #[test]\n    #[serial]\n    fn test_find_system_tool_returns_none_with_no_real_system_tool() {\n        let _guard = EnvGuard::new();\n        let temp = TempDir::new().unwrap();\n        let install_a_bin = temp.path().join(\"install_a_bin\");\n        let install_b_bin = temp.path().join(\"install_b_bin\");\n        std::fs::create_dir_all(&install_a_bin).unwrap();\n        std::fs::create_dir_all(&install_b_bin).unwrap();\n        create_fake_executable(&install_a_bin, \"mytesttool\");\n        create_fake_executable(&install_b_bin, \"mytesttool\");\n\n        let path =\n            std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap();\n        let bypass =\n            std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap();\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PATH\", &path);\n            std::env::set_var(env_vars::VITE_PLUS_BYPASS, &bypass);\n        }\n\n        let result = find_system_tool(\"mytesttool\");\n        assert!(\n            result.is_none(),\n            \"Should return None when all dirs are bypassed and no real system tool exists\"\n        );\n    }\n\n    // --- parse_npm_global_install tests ---\n\n    fn s(strs: &[&str]) -> Vec<String> {\n        strs.iter().map(|s| s.to_string()).collect()\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_basic() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"-g\", \"typescript\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"typescript\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_shorthand() {\n        let result = parse_npm_global_install(&s(&[\"i\", \"-g\", \"typescript@5.0.0\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"typescript@5.0.0\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_global_first() {\n        let result = parse_npm_global_install(&s(&[\"-g\", \"install\", \"pkg1\", \"pkg2\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"pkg1\", \"pkg2\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_long_global() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"--global\", \"@scope/pkg\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"@scope/pkg\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_not_uninstall() {\n        let result = parse_npm_global_install(&s(&[\"uninstall\", \"-g\", \"typescript\"]));\n        assert!(result.is_none(), \"uninstall should not be detected\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_no_global_flag() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"typescript\"]));\n        assert!(result.is_none(), \"no -g flag should return None\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_no_packages() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"-g\"]));\n        assert!(result.is_none(), \"no packages should return None\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_local_path() {\n        // Local paths are supported (read package.json to resolve name)\n        let result = parse_npm_global_install(&s(&[\"install\", \"-g\", \"./local\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"./local\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_skip_registry() {\n        let result =\n            parse_npm_global_install(&s(&[\"install\", \"-g\", \"--registry\", \"https://x\", \"pkg\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"pkg\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_not_run_subcommand() {\n        let result = parse_npm_global_install(&s(&[\"run\", \"build\", \"-g\"]));\n        assert!(result.is_none(), \"run is not an install subcommand\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_git_url() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"-g\", \"git+https://repo\"]));\n        assert!(result.is_none(), \"git+ URLs should be filtered\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_url() {\n        let result =\n            parse_npm_global_install(&s(&[\"install\", \"-g\", \"https://example.com/pkg.tgz\"]));\n        assert!(result.is_none(), \"URLs should be filtered\");\n    }\n\n    // --- parse_npm_global_uninstall tests ---\n\n    #[test]\n    fn test_parse_npm_global_uninstall_basic() {\n        let result = parse_npm_global_uninstall(&s(&[\"uninstall\", \"-g\", \"typescript\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"typescript\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_shorthand_un() {\n        let result = parse_npm_global_uninstall(&s(&[\"un\", \"-g\", \"typescript\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"typescript\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_shorthand_rm() {\n        let result = parse_npm_global_uninstall(&s(&[\"rm\", \"--global\", \"pkg1\", \"pkg2\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"pkg1\", \"pkg2\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_remove() {\n        let result = parse_npm_global_uninstall(&s(&[\"remove\", \"-g\", \"@scope/pkg\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"@scope/pkg\"]);\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_not_install() {\n        let result = parse_npm_global_uninstall(&s(&[\"install\", \"-g\", \"typescript\"]));\n        assert!(result.is_none(), \"install should not be detected as uninstall\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_no_global_flag() {\n        let result = parse_npm_global_uninstall(&s(&[\"uninstall\", \"typescript\"]));\n        assert!(result.is_none(), \"no -g flag should return None\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_no_packages() {\n        let result = parse_npm_global_uninstall(&s(&[\"uninstall\", \"-g\"]));\n        assert!(result.is_none(), \"no packages should return None\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_run_subcommand_with_install_arg() {\n        // `npm run install -g` — \"run\" is the first positional, so \"install\" is NOT the subcommand\n        let result = parse_npm_global_install(&s(&[\"run\", \"install\", \"-g\"]));\n        assert!(result.is_none(), \"install after run should not be detected as npm install\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_run_subcommand_with_uninstall_arg() {\n        // `npm run uninstall -g foo` — \"run\" is first positional, \"uninstall\" is a script arg\n        let result = parse_npm_global_uninstall(&s(&[\"run\", \"uninstall\", \"-g\", \"foo\"]));\n        assert!(result.is_none(), \"uninstall after run should not be detected as npm uninstall\");\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_flag_before_subcommand() {\n        // `npm -g install pkg` — flags don't consume the positional slot\n        let result = parse_npm_global_install(&s(&[\"-g\", \"install\", \"pkg\"]));\n        assert!(result.is_some());\n        assert_eq!(result.unwrap().packages, vec![\"pkg\"]);\n    }\n\n    // --- resolve_package_name tests ---\n\n    #[test]\n    fn test_resolve_package_name_simple() {\n        assert_eq!(resolve_package_name(\"codex\"), Some(\"codex\".to_string()));\n    }\n\n    #[test]\n    fn test_resolve_package_name_with_version() {\n        assert_eq!(resolve_package_name(\"typescript@5.0.0\"), Some(\"typescript\".to_string()));\n    }\n\n    #[test]\n    fn test_resolve_package_name_scoped() {\n        assert_eq!(resolve_package_name(\"@scope/pkg\"), Some(\"@scope/pkg\".to_string()));\n    }\n\n    #[test]\n    fn test_resolve_package_name_scoped_with_version() {\n        assert_eq!(resolve_package_name(\"@scope/pkg@1.0.0\"), Some(\"@scope/pkg\".to_string()));\n    }\n\n    #[test]\n    fn test_resolve_package_name_local_path_with_package_json() {\n        let temp = TempDir::new().unwrap();\n        let pkg_dir = temp.path().join(\"my-pkg\");\n        std::fs::create_dir_all(&pkg_dir).unwrap();\n        std::fs::write(pkg_dir.join(\"package.json\"), r#\"{\"name\": \"my-actual-pkg\"}\"#).unwrap();\n\n        let spec = pkg_dir.to_str().unwrap();\n        // Use absolute path starting with /\n        assert_eq!(resolve_package_name(spec), Some(\"my-actual-pkg\".to_string()));\n    }\n\n    #[test]\n    fn test_resolve_package_name_local_path_no_package_json() {\n        assert_eq!(resolve_package_name(\"./nonexistent\"), None);\n    }\n\n    // --- extract_bin_names tests ---\n\n    #[test]\n    fn test_extract_bin_names_single() {\n        let json: serde_json::Value =\n            serde_json::from_str(r#\"{\"name\": \"my-pkg\", \"bin\": \"./cli.js\"}\"#).unwrap();\n        assert_eq!(extract_bin_names(&json), vec![\"my-pkg\"]);\n    }\n\n    #[test]\n    fn test_extract_bin_names_scoped_single() {\n        let json: serde_json::Value =\n            serde_json::from_str(r#\"{\"name\": \"@scope/my-pkg\", \"bin\": \"./cli.js\"}\"#).unwrap();\n        assert_eq!(extract_bin_names(&json), vec![\"my-pkg\"]);\n    }\n\n    #[test]\n    fn test_extract_bin_names_object() {\n        let json: serde_json::Value = serde_json::from_str(\n            r#\"{\"name\": \"pkg\", \"bin\": {\"cli-a\": \"./a.js\", \"cli-b\": \"./b.js\"}}\"#,\n        )\n        .unwrap();\n        let mut names = extract_bin_names(&json);\n        names.sort();\n        assert_eq!(names, vec![\"cli-a\", \"cli-b\"]);\n    }\n\n    #[test]\n    fn test_extract_bin_names_no_bin() {\n        let json: serde_json::Value = serde_json::from_str(r#\"{\"name\": \"pkg\"}\"#).unwrap();\n        assert!(extract_bin_names(&json).is_empty());\n    }\n\n    // --- is_local_path tests ---\n\n    #[test]\n    fn test_is_local_path_bare_dot() {\n        assert!(is_local_path(\".\"));\n    }\n\n    #[test]\n    fn test_is_local_path_bare_dotdot() {\n        assert!(is_local_path(\"..\"));\n    }\n\n    #[test]\n    fn test_is_local_path_relative_dot() {\n        assert!(is_local_path(\"./foo\"));\n        assert!(is_local_path(\"../bar\"));\n    }\n\n    #[test]\n    fn test_is_local_path_absolute() {\n        assert!(is_local_path(\"/usr/local/pkg\"));\n    }\n\n    #[test]\n    fn test_is_local_path_package_name() {\n        assert!(!is_local_path(\"typescript\"));\n        assert!(!is_local_path(\"@scope/pkg\"));\n        assert!(!is_local_path(\"pkg@1.0.0\"));\n    }\n\n    #[cfg(windows)]\n    #[test]\n    fn test_is_local_path_windows_drive() {\n        assert!(is_local_path(\"C:\\\\pkg\"));\n        assert!(is_local_path(\"D:/projects/my-pkg\"));\n        assert!(!is_local_path(\"C\")); // too short\n    }\n\n    // --- dedup missing_bins tests ---\n\n    #[test]\n    fn test_dedup_missing_bins_keeps_last_entry() {\n        // Simulates: `npm install -g pkg-a pkg-b` where both declare bin \"shared-cli\".\n        // After dedup, only the last entry (pkg-b) should survive — npm's \"last writer wins\".\n        let temp = TempDir::new().unwrap();\n        let source_a =\n            AbsolutePathBuf::new(temp.path().join(\"node_modules/.bin/shared-cli\")).unwrap();\n        let source_b =\n            AbsolutePathBuf::new(temp.path().join(\"node_modules/.bin/shared-cli\")).unwrap();\n\n        let missing_bins: Vec<(String, AbsolutePathBuf, String)> = vec![\n            (\"shared-cli\".to_string(), source_a, \"pkg-a\".to_string()),\n            (\"shared-cli\".to_string(), source_b, \"pkg-b\".to_string()),\n        ];\n\n        // Apply the same dedup logic used in check_npm_global_install_result\n        let deduped = dedup_missing_bins(missing_bins);\n\n        assert_eq!(deduped.len(), 1, \"Should have exactly one entry after dedup\");\n        assert_eq!(deduped[0].0, \"shared-cli\");\n        assert_eq!(deduped[0].2, \"pkg-b\", \"Last writer (pkg-b) should win\");\n    }\n\n    #[test]\n    fn test_dedup_missing_bins_preserves_unique_entries() {\n        let temp = TempDir::new().unwrap();\n        let source_a = AbsolutePathBuf::new(temp.path().join(\"bin/cli-a\")).unwrap();\n        let source_b = AbsolutePathBuf::new(temp.path().join(\"bin/cli-b\")).unwrap();\n\n        let missing_bins: Vec<(String, AbsolutePathBuf, String)> = vec![\n            (\"cli-a\".to_string(), source_a, \"pkg-a\".to_string()),\n            (\"cli-b\".to_string(), source_b, \"pkg-b\".to_string()),\n        ];\n\n        let deduped = dedup_missing_bins(missing_bins);\n\n        assert_eq!(deduped.len(), 2, \"Unique entries should be preserved\");\n        assert_eq!(deduped[0].0, \"cli-a\");\n        assert_eq!(deduped[1].0, \"cli-b\");\n    }\n\n    #[test]\n    fn test_dedup_missing_bins_multiple_dupes() {\n        // Three packages all declare \"shared\" and two packages declare \"other\"\n        let temp = TempDir::new().unwrap();\n        let src = |name: &str| AbsolutePathBuf::new(temp.path().join(name)).unwrap();\n\n        let missing_bins: Vec<(String, AbsolutePathBuf, String)> = vec![\n            (\"shared\".to_string(), src(\"s1\"), \"pkg-a\".to_string()),\n            (\"other\".to_string(), src(\"o1\"), \"pkg-a\".to_string()),\n            (\"shared\".to_string(), src(\"s2\"), \"pkg-b\".to_string()),\n            (\"shared\".to_string(), src(\"s3\"), \"pkg-c\".to_string()),\n            (\"other\".to_string(), src(\"o2\"), \"pkg-c\".to_string()),\n        ];\n\n        let deduped = dedup_missing_bins(missing_bins);\n\n        assert_eq!(deduped.len(), 2);\n        // \"shared\" last writer is pkg-c, \"other\" last writer is pkg-c\n        assert_eq!(deduped[0].0, \"shared\");\n        assert_eq!(deduped[0].2, \"pkg-c\");\n        assert_eq!(deduped[1].0, \"other\");\n        assert_eq!(deduped[1].2, \"pkg-c\");\n    }\n\n    // --- parse_npm_global_command --prefix tests ---\n\n    #[test]\n    fn test_parse_npm_global_install_with_prefix() {\n        let result =\n            parse_npm_global_install(&s(&[\"install\", \"-g\", \"--prefix\", \"/tmp/test\", \"pkg\"]));\n        assert!(result.is_some());\n        let parsed = result.unwrap();\n        assert_eq!(parsed.packages, vec![\"pkg\"]);\n        assert_eq!(parsed.explicit_prefix.as_deref(), Some(\"/tmp/test\"));\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_with_prefix_equals() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"-g\", \"--prefix=/tmp/test\", \"pkg\"]));\n        assert!(result.is_some());\n        let parsed = result.unwrap();\n        assert_eq!(parsed.packages, vec![\"pkg\"]);\n        assert_eq!(parsed.explicit_prefix.as_deref(), Some(\"/tmp/test\"));\n    }\n\n    #[test]\n    fn test_parse_npm_global_install_without_prefix() {\n        let result = parse_npm_global_install(&s(&[\"install\", \"-g\", \"pkg\"]));\n        assert!(result.is_some());\n        let parsed = result.unwrap();\n        assert_eq!(parsed.packages, vec![\"pkg\"]);\n        assert!(parsed.explicit_prefix.is_none());\n    }\n\n    #[test]\n    fn test_parse_npm_global_uninstall_with_prefix() {\n        let result =\n            parse_npm_global_uninstall(&s(&[\"uninstall\", \"-g\", \"--prefix\", \"/custom/dir\", \"pkg\"]));\n        assert!(result.is_some());\n        let parsed = result.unwrap();\n        assert_eq!(parsed.packages, vec![\"pkg\"]);\n        assert_eq!(parsed.explicit_prefix.as_deref(), Some(\"/custom/dir\"));\n    }\n\n    // --- resolve_npm_prefix tests ---\n\n    #[test]\n    #[serial]\n    fn test_resolve_npm_prefix_relative() {\n        let temp = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();\n\n        // SAFETY: This test runs in isolation with serial_test\n        unsafe {\n            std::env::set_var(\"PWD\", temp_path.as_path());\n        }\n\n        let parsed = NpmGlobalCommand {\n            packages: vec![\"pkg\".to_string()],\n            explicit_prefix: Some(\"./custom\".to_string()),\n        };\n        // Use a dummy npm_path and node_dir (should not be reached)\n        let dummy_dir = temp_path.join(\"dummy\");\n        let result = resolve_npm_prefix(&parsed, &dummy_dir, &dummy_dir);\n        // Should resolve relative to cwd, not fall back to get_npm_global_prefix\n        assert!(\n            result.as_path().ends_with(\"custom\"),\n            \"Expected path ending with 'custom', got: {}\",\n            result.as_path().display()\n        );\n    }\n\n    #[test]\n    #[serial]\n    fn test_resolve_npm_prefix_absolute() {\n        let temp = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();\n        let abs_prefix = temp_path.join(\"abs-prefix\");\n\n        let parsed = NpmGlobalCommand {\n            packages: vec![\"pkg\".to_string()],\n            explicit_prefix: Some(abs_prefix.as_path().display().to_string()),\n        };\n        let dummy_dir = temp_path.join(\"dummy\");\n        let result = resolve_npm_prefix(&parsed, &dummy_dir, &dummy_dir);\n        assert_eq!(\n            result.as_path(),\n            abs_prefix.as_path(),\n            \"Absolute prefix should be returned as-is\"\n        );\n    }\n\n    #[test]\n    fn test_resolve_npm_prefix_none_fallback() {\n        // When no explicit prefix, resolve_npm_prefix calls get_npm_global_prefix.\n        // We can't easily test that fallback without a real npm, so just verify\n        // it doesn't panic and returns some path.\n        let temp = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();\n        let parsed = NpmGlobalCommand { packages: vec![], explicit_prefix: None };\n        let dummy_dir = temp_path.join(\"dummy\");\n        // This will fall back to get_npm_global_prefix, which may fail but should\n        // ultimately return node_dir as the final fallback\n        let result = resolve_npm_prefix(&parsed, &dummy_dir, &dummy_dir);\n        assert!(!result.as_path().as_os_str().is_empty());\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/shim/exec.rs",
    "content": "//! Platform-specific execution for shim operations.\n//!\n//! On Unix, uses execve to replace the current process.\n//! On Windows, spawns the process and waits for completion.\n\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\n/// Convert a process ExitStatus to an exit code.\n/// On Unix, if the process was killed by a signal, returns 128 + signal_number.\nfn exit_code_from_status(status: std::process::ExitStatus) -> i32 {\n    #[cfg(unix)]\n    {\n        use std::os::unix::process::ExitStatusExt;\n        if let Some(signal) = status.signal() {\n            return 128 + signal;\n        }\n    }\n    status.code().unwrap_or(1)\n}\n\n/// Spawn a tool as a child process and wait for completion.\n///\n/// Unlike `exec_tool()`, this does NOT replace the current process on Unix,\n/// allowing the caller to run code after the tool exits.\npub fn spawn_tool(path: &AbsolutePath, args: &[String]) -> i32 {\n    match std::process::Command::new(path.as_path()).args(args).status() {\n        Ok(status) => exit_code_from_status(status),\n        Err(e) => {\n            output::error(&format!(\"Failed to execute {}: {}\", path.as_path().display(), e));\n            1\n        }\n    }\n}\n\n/// Execute a tool, replacing the current process on Unix.\n///\n/// Returns an exit code on Windows or if exec fails on Unix.\npub fn exec_tool(path: &AbsolutePath, args: &[String]) -> i32 {\n    #[cfg(unix)]\n    {\n        exec_unix(path, args)\n    }\n\n    #[cfg(windows)]\n    {\n        exec_windows(path, args)\n    }\n}\n\n/// Unix: Use exec to replace the current process.\n#[cfg(unix)]\nfn exec_unix(path: &AbsolutePath, args: &[String]) -> i32 {\n    use std::os::unix::process::CommandExt;\n\n    let mut cmd = std::process::Command::new(path.as_path());\n    cmd.args(args);\n\n    // exec replaces the current process - this only returns on error\n    let err = cmd.exec();\n    output::error(&format!(\"Failed to exec {}: {}\", path.as_path().display(), err));\n    1\n}\n\n/// Windows: Spawn the process and wait for completion.\n#[cfg(windows)]\nfn exec_windows(path: &AbsolutePath, args: &[String]) -> i32 {\n    spawn_tool(path, args)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[cfg(unix)]\n    #[test]\n    fn test_exit_code_from_status_normal() {\n        let status =\n            std::process::Command::new(\"/bin/sh\").arg(\"-c\").arg(\"exit 42\").status().unwrap();\n        assert_eq!(exit_code_from_status(status), 42);\n    }\n\n    #[cfg(windows)]\n    #[test]\n    fn test_exit_code_from_status_normal() {\n        let status = std::process::Command::new(\"cmd\").args([\"/C\", \"exit 42\"]).status().unwrap();\n        assert_eq!(exit_code_from_status(status), 42);\n    }\n\n    #[cfg(unix)]\n    #[test]\n    fn test_exit_code_from_status_signal() {\n        // Process kills itself with SIGINT (signal 2), expected exit code: 128 + 2 = 130\n        let status =\n            std::process::Command::new(\"/bin/sh\").arg(\"-c\").arg(\"kill -INT $$\").status().unwrap();\n        assert_eq!(exit_code_from_status(status), 130);\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/shim/mod.rs",
    "content": "//! Shim module for intercepting node, npm, npx, and package binary commands.\n//!\n//! This module provides the functionality for the vp binary to act as a shim\n//! when invoked as `node`, `npm`, `npx`, or any globally installed package binary.\n//!\n//! Detection methods:\n//! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection\n//! - Windows: Trampoline `.exe` files set `VITE_PLUS_SHIM_TOOL` env var and spawn vp.exe\n//! - Legacy: `.cmd` wrappers call `vp env exec <tool>` directly (deprecated)\n\nmod cache;\npub(crate) mod dispatch;\npub(crate) mod exec;\n\npub(crate) use cache::invalidate_cache;\npub use dispatch::dispatch;\nuse vite_shared::env_vars;\n\n/// Core shim tools (node, npm, npx)\npub const CORE_SHIM_TOOLS: &[&str] = &[\"node\", \"npm\", \"npx\"];\n\n/// Extract the tool name from argv[0].\n///\n/// Handles various formats:\n/// - `node` (Unix)\n/// - `/usr/bin/node` (Unix full path)\n/// - `node.exe` (Windows)\n/// - `C:\\path\\node.exe` (Windows full path)\npub fn extract_tool_name(argv0: &str) -> String {\n    let path = std::path::Path::new(argv0);\n    let stem = path.file_stem().unwrap_or_default().to_string_lossy();\n\n    // Handle Windows: strip .exe, .cmd extensions if present in stem\n    // (file_stem already strips the extension)\n    stem.to_lowercase()\n}\n\n/// Check if the given tool name is a core shim tool (node/npm/npx).\n#[must_use]\npub fn is_core_shim_tool(tool: &str) -> bool {\n    CORE_SHIM_TOOLS.contains(&tool)\n}\n\n/// Check if the given tool name is a shim tool (core or package binary).\n///\n/// This is a quick check that returns true if:\n/// 1. The tool is a core shim (node/npm/npx), OR\n/// 2. The tool name is not \"vp\" (package binaries are detected later via metadata)\n#[must_use]\npub fn is_shim_tool(tool: &str) -> bool {\n    // Core tools are always shims\n    if is_core_shim_tool(tool) {\n        return true;\n    }\n    // \"vp\" is not a shim - it's the main CLI\n    if tool == \"vp\" {\n        return false;\n    }\n    // For other tools, we need to check if they're package binaries\n    // This is a heuristic - we'll check metadata in dispatch\n    // We assume anything invoked from the bin directory is a shim\n    is_potential_package_binary(tool)\n}\n\n/// Check if the tool could be a package binary shim.\n///\n/// Returns true if a shim for the tool exists in the configured bin directory.\n/// This check respects the VITE_PLUS_HOME environment variable for custom home directories.\n///\n/// Note: We check the configured bin directory directly instead of using current_exe()\n/// because when running through a wrapper script (e.g., current/bin/vp), the current_exe()\n/// returns the wrapper's location, not the original shim's location.\nfn is_potential_package_binary(tool: &str) -> bool {\n    use crate::commands::env::config;\n\n    // Get the configured bin directory (respects VITE_PLUS_HOME env var)\n    let Ok(configured_bin) = config::get_bin_dir() else {\n        return false;\n    };\n\n    // Check if the shim exists in the configured bin directory.\n    // Use symlink_metadata to detect symlinks (even broken ones).\n    // On Windows, check .exe first (trampoline shims, the common case),\n    // then fall back to extensionless (Unix symlinks or legacy).\n    #[cfg(windows)]\n    {\n        let exe_path = configured_bin.join(format!(\"{tool}.exe\"));\n        if std::fs::symlink_metadata(&exe_path).is_ok() {\n            return true;\n        }\n    }\n\n    let shim_path = configured_bin.join(tool);\n    if std::fs::symlink_metadata(&shim_path).is_ok() {\n        return true;\n    }\n\n    false\n}\n\n/// Environment variable used for shim tool detection via shell wrapper scripts.\nconst SHIM_TOOL_ENV_VAR: &str = env_vars::VITE_PLUS_SHIM_TOOL;\n\n/// Detect the shim tool from environment and argv.\n///\n/// Detection priority:\n/// 1. Check `VITE_PLUS_SHIM_TOOL` env var (set by trampoline exe on Windows)\n/// 2. If argv[0] is \"vp\" or \"vp.exe\", this is a direct CLI invocation - NOT shim mode\n/// 3. Fall back to argv[0] detection (primary method on Unix with symlinks)\n///\n/// IMPORTANT: This function clears `VITE_PLUS_SHIM_TOOL` after reading it to\n/// prevent the env var from leaking to child processes.\npub fn detect_shim_tool(argv0: &str) -> Option<String> {\n    // Always clear the env var to prevent it from leaking to child processes.\n    // We read it first, then clear it immediately.\n    // SAFETY: We're at program startup before any threads are spawned.\n    let env_tool = std::env::var(SHIM_TOOL_ENV_VAR).ok();\n    unsafe {\n        std::env::remove_var(SHIM_TOOL_ENV_VAR);\n    }\n\n    // Check VITE_PLUS_SHIM_TOOL env var first (set by trampoline exe on Windows).\n    // This takes priority over argv[0] because the trampoline spawns vp.exe\n    // (so argv[0] would be \"vp\"), but the env var carries the real tool name.\n    if let Some(tool) = env_tool {\n        if !tool.is_empty() {\n            let tool_lower = tool.to_lowercase();\n            // Accept any tool from env var (could be core or package binary)\n            if tool_lower != \"vp\" {\n                return Some(tool_lower);\n            }\n        }\n    }\n\n    // If argv[0] is explicitly \"vp\" or \"vp.exe\", this is a direct CLI invocation.\n    let argv0_tool = extract_tool_name(argv0);\n    if argv0_tool == \"vp\" {\n        return None; // Direct vp invocation, not shim mode\n    }\n    if argv0_tool == \"vpx\" {\n        return Some(\"vpx\".to_string());\n    }\n\n    // Fall back to argv[0] detection (Unix symlinks)\n    if is_shim_tool(&argv0_tool) { Some(argv0_tool) } else { None }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_extract_tool_name() {\n        assert_eq!(extract_tool_name(\"node\"), \"node\");\n        assert_eq!(extract_tool_name(\"/usr/bin/node\"), \"node\");\n        assert_eq!(extract_tool_name(\"/home/user/.vite-plus/bin/node\"), \"node\");\n        assert_eq!(extract_tool_name(\"npm\"), \"npm\");\n        assert_eq!(extract_tool_name(\"npx\"), \"npx\");\n        assert_eq!(extract_tool_name(\"vp\"), \"vp\");\n\n        // Files with extensions (works on all platforms)\n        assert_eq!(extract_tool_name(\"node.exe\"), \"node\");\n        assert_eq!(extract_tool_name(\"npm.cmd\"), \"npm\");\n\n        // Windows paths - only test on Windows\n        #[cfg(windows)]\n        {\n            assert_eq!(extract_tool_name(\"C:\\\\Users\\\\user\\\\.vite-plus\\\\bin\\\\node.exe\"), \"node\");\n        }\n    }\n\n    #[test]\n    fn test_is_shim_tool() {\n        // Core shim tools are always recognized\n        assert!(is_core_shim_tool(\"node\"));\n        assert!(is_core_shim_tool(\"npm\"));\n        assert!(is_core_shim_tool(\"npx\"));\n        assert!(!is_core_shim_tool(\"yarn\")); // yarn is not a core shim tool\n        assert!(!is_core_shim_tool(\"vp\"));\n        assert!(!is_core_shim_tool(\"cargo\"));\n        assert!(!is_core_shim_tool(\"tsc\")); // Package binary, not core\n\n        // is_shim_tool includes core tools\n        assert!(is_shim_tool(\"node\"));\n        assert!(is_shim_tool(\"npm\"));\n        assert!(is_shim_tool(\"npx\"));\n        assert!(!is_shim_tool(\"vp\")); // vp is never a shim\n    }\n\n    /// Test that is_potential_package_binary checks the configured bin directory.\n    ///\n    /// The function now checks if a shim exists in the configured bin directory\n    /// (from VITE_PLUS_HOME/bin) instead of relying on current_exe().\n    /// This allows it to work correctly with wrapper scripts.\n    #[test]\n    fn test_is_potential_package_binary_checks_configured_bin() {\n        // The function checks config::get_bin_dir() which respects VITE_PLUS_HOME.\n        // Without setting VITE_PLUS_HOME, it defaults to ~/.vite-plus/bin.\n        //\n        // Since we can't easily create test shims in the actual bin directory,\n        // we just verify the function doesn't panic and returns false for\n        // non-existent tools.\n        assert!(!is_potential_package_binary(\"nonexistent-tool-12345\"));\n        assert!(!is_potential_package_binary(\"another-fake-tool\"));\n    }\n\n    #[test]\n    fn test_detect_shim_tool_vpx() {\n        // vpx should be detected via the argv0 check, before the env var check\n        // and before is_shim_tool (which would incorrectly match it as a package binary)\n        // SAFETY: We're in a test\n        unsafe {\n            std::env::remove_var(SHIM_TOOL_ENV_VAR);\n        }\n        let result = detect_shim_tool(\"vpx\");\n        assert_eq!(result, Some(\"vpx\".to_string()));\n\n        // Also works with full path\n        let result = detect_shim_tool(\"/home/user/.vite-plus/bin/vpx\");\n        assert_eq!(result, Some(\"vpx\".to_string()));\n\n        // Also works with .exe extension (Windows)\n        let result = detect_shim_tool(\"vpx.exe\");\n        assert_eq!(result, Some(\"vpx\".to_string()));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/tips/mod.rs",
    "content": "//! CLI tips system for providing helpful suggestions to users.\n//!\n//! Tips are shown after command execution to help users discover features\n//! and shortcuts.\n\nmod short_aliases;\nmod use_vpx_or_run;\n\nuse clap::error::ErrorKind as ClapErrorKind;\n\nuse self::{short_aliases::ShortAliases, use_vpx_or_run::UseVpxOrRun};\n\n/// Execution context passed in from the CLI entry point.\npub struct TipContext {\n    /// CLI arguments as typed by the user, excluding the program name (`vp`).\n    pub raw_args: Vec<String>,\n    /// The exit code of the command (0 = success, non-zero = failure).\n    pub exit_code: i32,\n    /// The clap error if parsing failed.\n    pub clap_error: Option<clap::Error>,\n}\n\nimpl Default for TipContext {\n    fn default() -> Self {\n        TipContext { raw_args: Vec::new(), exit_code: 0, clap_error: None }\n    }\n}\n\nimpl TipContext {\n    /// Whether the command completed successfully.\n    #[expect(dead_code)]\n    pub fn success(&self) -> bool {\n        self.exit_code == 0\n    }\n\n    pub fn is_unknown_command_error(&self) -> bool {\n        if let Some(err) = &self.clap_error {\n            matches!(err.kind(), ClapErrorKind::InvalidSubcommand)\n        } else {\n            false\n        }\n    }\n\n    /// Iterate positional args (skipping flags starting with `-`).\n    fn positionals(&self) -> impl Iterator<Item = &str> {\n        self.raw_args.iter().map(String::as_str).filter(|a| !a.starts_with('-'))\n    }\n\n    /// The subcommand (first positional arg, e.g., \"ls\", \"build\").\n    pub fn subcommand(&self) -> Option<&str> {\n        self.positionals().next()\n    }\n\n    /// Whether the positional args start with the given command pattern.\n    /// Pattern is space-separated: \"pm list\" matches even if flags are interspersed.\n    #[expect(dead_code)]\n    pub fn is_subcommand(&self, pattern: &str) -> bool {\n        let mut positionals = self.positionals();\n        pattern.split_whitespace().all(|expected| positionals.next() == Some(expected))\n    }\n}\n\n/// A tip that can be shown to the user after command execution.\npub trait Tip {\n    /// Whether this tip is relevant given the current execution context.\n    fn matches(&self, ctx: &TipContext) -> bool;\n    /// The tip text shown to the user.\n    fn message(&self) -> &'static str;\n}\n\n/// Returns all registered tips.\nfn all() -> &'static [&'static dyn Tip] {\n    &[&ShortAliases, &UseVpxOrRun]\n}\n\n/// Pick a random tip from those matching the current context.\n///\n/// Returns `None` if:\n/// - The `VITE_PLUS_CLI_TEST` env var is set (test mode)\n/// - No tips match the given context\npub fn get_tip(context: &TipContext) -> Option<&'static str> {\n    if std::env::var_os(\"VITE_PLUS_CLI_TEST\").is_some() || std::env::var_os(\"CI\").is_some() {\n        return None;\n    }\n\n    let now =\n        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();\n\n    let all = all();\n    let matching: Vec<&&dyn Tip> = all.iter().filter(|t| t.matches(context)).collect();\n\n    if matching.is_empty() {\n        return None;\n    }\n\n    // Use subsec_nanos for random tip selection\n    let nanos = now.subsec_nanos() as usize;\n    Some(matching[nanos % matching.len()].message())\n}\n\n/// Create a `TipContext` from a command string using real clap parsing.\n///\n/// `command` is exactly what the user types in the terminal (e.g. `\"vp list --flag\"`).\n/// The first arg is treated as the program name and excluded from `raw_args`,\n/// matching how the real CLI uses `std::env::args()`.\n#[cfg(test)]\npub fn tip_context_from_command(command: &str) -> TipContext {\n    // Split simulates what the OS does with command line args\n    let args: Vec<String> = command.split_whitespace().map(String::from).collect();\n\n    let (exit_code, clap_error) = match crate::try_parse_args_from(args.iter().cloned()) {\n        Ok(_) => (0, None),\n        Err(e) => (e.exit_code(), Some(e)),\n    };\n\n    // raw_args excludes program name (args[0]), same as real CLI: args[1..].to_vec()\n    let raw_args = args.get(1..).map(<[String]>::to_vec).unwrap_or_default();\n\n    TipContext { raw_args, exit_code, clap_error }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/tips/short_aliases.rs",
    "content": "//! Tip suggesting short aliases for long-form commands.\n\nuse super::{Tip, TipContext};\n\n/// Long-form commands that have short aliases.\nconst LONG_FORMS: &[&str] = &[\"install\", \"remove\", \"uninstall\", \"update\", \"list\", \"link\"];\n\n/// Suggest short aliases when user runs a long-form command.\npub struct ShortAliases;\n\nimpl Tip for ShortAliases {\n    fn matches(&self, ctx: &TipContext) -> bool {\n        ctx.subcommand().is_some_and(|cmd| LONG_FORMS.contains(&cmd))\n    }\n\n    fn message(&self) -> &'static str {\n        \"Available short aliases: i = install, rm = remove, un = uninstall, up = update, ls = list, ln = link\"\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::tips::tip_context_from_command;\n\n    #[test]\n    fn matches_long_form_commands() {\n        for cmd in LONG_FORMS {\n            let ctx = tip_context_from_command(&format!(\"vp {cmd}\"));\n            assert!(ShortAliases.matches(&ctx), \"should match {cmd}\");\n        }\n    }\n\n    #[test]\n    fn does_not_match_short_form_commands() {\n        let short_forms = [\"i\", \"rm\", \"un\", \"up\", \"ln\"];\n        for cmd in short_forms {\n            let ctx = tip_context_from_command(&format!(\"vp {cmd}\"));\n            assert!(!ShortAliases.matches(&ctx), \"should not match {cmd}\");\n        }\n    }\n\n    #[test]\n    fn does_not_match_other_commands() {\n        let other_commands = [\"build\", \"test\", \"lint\", \"run\", \"pack\"];\n        for cmd in other_commands {\n            let ctx = tip_context_from_command(&format!(\"vp {cmd}\"));\n            assert!(!ShortAliases.matches(&ctx), \"should not match {cmd}\");\n        }\n    }\n\n    #[test]\n    fn install_shows_short_alias_tip() {\n        let ctx = tip_context_from_command(\"vp install\");\n        assert!(ShortAliases.matches(&ctx));\n    }\n\n    #[test]\n    fn short_form_does_not_show_tip() {\n        let ctx = tip_context_from_command(\"vp i\");\n        assert!(!ShortAliases.matches(&ctx));\n    }\n}\n"
  },
  {
    "path": "crates/vite_global_cli/src/tips/use_vpx_or_run.rs",
    "content": "//! Tip suggesting vpx or vp run for unknown commands.\n\nuse super::{Tip, TipContext};\n\n/// Suggest `vpx <pkg>` or `vp run <script>` when an unknown command is used.\npub struct UseVpxOrRun;\n\nimpl Tip for UseVpxOrRun {\n    fn matches(&self, ctx: &TipContext) -> bool {\n        ctx.is_unknown_command_error()\n    }\n\n    fn message(&self) -> &'static str {\n        \"Execute a package binary with `vpx <pkg[@version]>`, or a script with `vp run <script>`\"\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::tips::tip_context_from_command;\n\n    #[test]\n    fn matches_on_unknown_command() {\n        let ctx = tip_context_from_command(\"vp typecheck\");\n        assert!(UseVpxOrRun.matches(&ctx));\n        assert!(ctx.is_unknown_command_error());\n    }\n\n    #[test]\n    fn does_not_match_on_known_command() {\n        let ctx = tip_context_from_command(\"vp build\");\n        assert!(!UseVpxOrRun.matches(&ctx));\n        assert!(!ctx.is_unknown_command_error());\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/Cargo.toml",
    "content": "[package]\nname = \"vite_install\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nbackon = { workspace = true }\ncrossterm = { workspace = true }\nflate2 = { workspace = true }\nfutures-util = { workspace = true }\nhex = { workspace = true }\nindoc = { workspace = true }\npathdiff = { workspace = true }\nsemver = { workspace = true }\nserde = { workspace = true, features = [\"derive\"] }\n# use `preserve_order` feature to preserve the order of the fields in `package.json`\nserde_json = { workspace = true, features = [\"preserve_order\"] }\nsha1 = { workspace = true }\nsha2 = { workspace = true }\ntar = { workspace = true }\ntempfile = { workspace = true }\ntokio = { workspace = true, features = [\"full\"] }\ntracing = { workspace = true }\nvite_command = { workspace = true }\nvite_error = { workspace = true }\nvite_glob = { workspace = true }\nvite_path = { workspace = true }\nvite_shared = { workspace = true }\nvite_str = { workspace = true }\nvite_workspace = { workspace = true }\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nreqwest = { workspace = true, features = [\"stream\", \"native-tls-vendored\", \"json\"] }\n\n[target.'cfg(not(target_os = \"windows\"))'.dependencies]\nreqwest = { workspace = true, features = [\"stream\", \"rustls-tls\", \"json\"] }\n\n[dev-dependencies]\nhttpmock = { workspace = true }\ntempfile = { workspace = true }\ntest-log = { workspace = true }\n\n[lints]\nworkspace = true\n\n[lib]\ndoctest = false\n"
  },
  {
    "path": "crates/vite_install/README.md",
    "content": "# vite_install\n\n- Auto-detects package manager type and version from package.json's `packageManager` field\n- Downloads and caches the specified version\n- Handles install, add, etc. commands for pnpm/yarn/npm.\n"
  },
  {
    "path": "crates/vite_install/src/commands/add.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// The type of dependency to save.\n#[derive(Debug, Default, Clone, Copy)]\npub enum SaveDependencyType {\n    /// Save as dependencies.\n    #[default]\n    Production,\n    /// Save as devDependencies.\n    Dev,\n    /// Save as peerDependencies.\n    Peer,\n    /// Save as optionalDependencies.\n    Optional,\n}\n\n#[derive(Debug, Default)]\npub struct AddCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub save_dependency_type: Option<SaveDependencyType>,\n    pub save_exact: bool,\n    pub save_catalog_name: Option<&'a str>,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub workspace_only: bool,\n    pub global: bool,\n    pub allow_build: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the add command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_add_command(\n        &self,\n        options: &AddCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_add_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the add command.\n    #[must_use]\n    pub fn resolve_add_command(&self, options: &AddCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        // global packages should use npm cli only\n        if options.global {\n            bin_name = \"npm\".into();\n            args.push(\"install\".into());\n            args.push(\"--global\".into());\n            if let Some(pass_through_args) = options.pass_through_args {\n                args.extend_from_slice(pass_through_args);\n            }\n            args.extend_from_slice(options.packages);\n\n            return ResolveCommandResult { bin_path: bin_name, args, envs };\n        }\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"add\".into());\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n                if options.workspace_only {\n                    args.push(\"--workspace\".into());\n                }\n\n                // https://pnpm.io/cli/add#options\n                if let Some(save_dependency_type) = options.save_dependency_type {\n                    match save_dependency_type {\n                        SaveDependencyType::Production => {\n                            args.push(\"--save-prod\".into());\n                        }\n                        SaveDependencyType::Dev => {\n                            args.push(\"--save-dev\".into());\n                        }\n                        SaveDependencyType::Peer => {\n                            args.push(\"--save-peer\".into());\n                        }\n                        SaveDependencyType::Optional => {\n                            args.push(\"--save-optional\".into());\n                        }\n                    }\n                }\n                if options.save_exact {\n                    args.push(\"--save-exact\".into());\n                }\n\n                if let Some(save_catalog_name) = options.save_catalog_name {\n                    if save_catalog_name.is_empty() {\n                        args.push(\"--save-catalog\".into());\n                    } else {\n                        args.push(format!(\"--save-catalog-name={}\", save_catalog_name));\n                    }\n                }\n\n                if let Some(allow_build) = options.allow_build {\n                    args.push(format!(\"--allow-build={}\", allow_build));\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                // yarn: workspaces foreach --all --include {filter} add\n                // https://yarnpkg.com/cli/workspaces/foreach\n                if let Some(filters) = options.filters {\n                    args.push(\"workspaces\".into());\n                    args.push(\"foreach\".into());\n                    args.push(\"--all\".into());\n                    for filter in filters {\n                        args.push(\"--include\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"add\".into());\n\n                // https://yarnpkg.com/cli/add#options\n                if let Some(save_dependency_type) = options.save_dependency_type {\n                    match save_dependency_type {\n                        SaveDependencyType::Production => {\n                            // default\n                            // no need to add anything\n                        }\n                        SaveDependencyType::Dev => {\n                            args.push(\"--dev\".into());\n                        }\n                        SaveDependencyType::Peer => {\n                            args.push(\"--peer\".into());\n                        }\n                        SaveDependencyType::Optional => {\n                            args.push(\"--optional\".into());\n                        }\n                    }\n                }\n                if options.save_exact {\n                    args.push(\"--exact\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                // npm: install --workspace <pkg>\n                args.push(\"install\".into());\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                // https://docs.npmjs.com/cli/v11/commands/npm-install#include-workspace-root\n                if options.workspace_root {\n                    args.push(\"--include-workspace-root\".into());\n                }\n\n                // https://docs.npmjs.com/cli/v11/commands/npm-install#configuration\n                if let Some(save_dependency_type) = options.save_dependency_type {\n                    match save_dependency_type {\n                        SaveDependencyType::Production => {\n                            args.push(\"--save\".into());\n                        }\n                        SaveDependencyType::Dev => {\n                            args.push(\"--save-dev\".into());\n                        }\n                        SaveDependencyType::Peer => {\n                            args.push(\"--save-peer\".into());\n                        }\n                        SaveDependencyType::Optional => {\n                            args.push(\"--save-optional\".into());\n                        }\n                    }\n                }\n\n                if options.save_exact {\n                    args.push(\"--save-exact\".into());\n                }\n            }\n        }\n\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n        args.extend_from_slice(options.packages);\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(\"1.0.0\"),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_basic_add() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: None,\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"add\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_add_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"add\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_add_with_save_catalog_name() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: Some(\"react18\"),\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\"--filter\", \"app\", \"add\", \"--save-catalog-name=react18\", \"react\"]\n        );\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_add_with_save_catalog_name_and_empty_name() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: Some(\"\"),\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"add\", \"--save-catalog\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_add_with_filter_and_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: true,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"add\", \"--workspace-root\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_add_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            save_dependency_type: Some(SaveDependencyType::Dev),\n            save_exact: false,\n            filters: None,\n            workspace_root: true,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"add\", \"--workspace-root\", \"--save-dev\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_add_workspace_only() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"@myorg/utils\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            workspace_only: true,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"add\", \"--workspace\", \"@myorg/utils\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_yarn_basic_add() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: None,\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"add\", \"react\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_add_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\"workspaces\", \"foreach\", \"--all\", \"--include\", \"app\", \"add\", \"react\"]\n        );\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_add_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            save_dependency_type: Some(SaveDependencyType::Dev),\n            save_exact: false,\n            filters: None,\n            workspace_root: true,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"add\", \"--dev\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_npm_basic_add() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: None,\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"install\", \"react\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_add_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"install\", \"--workspace\", \"app\", \"react\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_add_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: None,\n            workspace_root: true,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"install\", \"--include-workspace-root\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_add_multiple_workspaces() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\"install\", \"--workspace\", \"app\", \"--workspace\", \"web\", \"lodash\"]\n        );\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_add_multiple_workspaces_and_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            workspace_root: true,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: None,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\n                \"install\",\n                \"--workspace\",\n                \"app\",\n                \"--workspace\",\n                \"web\",\n                \"--include-workspace-root\",\n                \"lodash\"\n            ]\n        );\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_pnpm_add_with_allow_build() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_add_command(&AddCommandOptions {\n            packages: &[\"react\".to_string()],\n            save_dependency_type: None,\n            save_exact: false,\n            filters: None,\n            workspace_root: false,\n            workspace_only: false,\n            global: false,\n            save_catalog_name: None,\n            allow_build: Some(\"react,napi\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"add\", \"--allow-build=react,napi\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/audit.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the audit command.\n#[derive(Debug)]\npub struct AuditCommandOptions<'a> {\n    pub fix: bool,\n    pub json: bool,\n    pub level: Option<&'a str>,\n    pub production: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the audit command with the package manager.\n    /// Returns ExitStatus with success (0) if the command is not supported.\n    #[must_use]\n    pub async fn run_audit_command(\n        &self,\n        options: &AuditCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let Some(resolve_command) = self.resolve_audit_command(options) else {\n            return Ok(ExitStatus::default());\n        };\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the audit command.\n    /// Returns None if the command is not supported by the package manager.\n    #[must_use]\n    pub fn resolve_audit_command(\n        &self,\n        options: &AuditCommandOptions,\n    ) -> Option<ResolveCommandResult> {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n\n                if options.fix {\n                    args.push(\"audit\".into());\n                    args.push(\"fix\".into());\n                } else {\n                    args.push(\"audit\".into());\n                }\n\n                if let Some(level) = options.level {\n                    args.push(\"--audit-level\".into());\n                    args.push(level.to_string());\n                }\n\n                if options.production {\n                    args.push(\"--omit=dev\".into());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n            }\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"audit\".into());\n\n                if options.fix {\n                    args.push(\"--fix\".into());\n                }\n\n                if let Some(level) = options.level {\n                    args.push(\"--audit-level\".into());\n                    args.push(level.to_string());\n                }\n\n                if options.production {\n                    args.push(\"--prod\".into());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                if is_yarn1 {\n                    if options.fix {\n                        output::warn(\"yarn v1 audit does not support --fix\");\n                        return None;\n                    }\n\n                    bin_name = \"yarn\".into();\n                    args.push(\"audit\".into());\n\n                    if let Some(level) = options.level {\n                        args.push(\"--level\".into());\n                        args.push(level.to_string());\n                    }\n\n                    if options.production {\n                        args.push(\"--groups\".into());\n                        args.push(\"dependencies\".into());\n                    }\n\n                    if options.json {\n                        args.push(\"--json\".into());\n                    }\n                } else {\n                    if options.fix {\n                        output::warn(\"yarn berry audit does not support --fix\");\n                        return None;\n                    }\n\n                    bin_name = \"yarn\".into();\n                    args.push(\"npm\".into());\n                    args.push(\"audit\".into());\n\n                    if let Some(level) = options.level {\n                        args.push(\"--severity\".into());\n                        args.push(level.to_string());\n                    }\n\n                    if options.production {\n                        args.push(\"--environment\".into());\n                        args.push(\"production\".into());\n                    }\n\n                    if options.json {\n                        args.push(\"--json\".into());\n                    }\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        Some(ResolveCommandResult { bin_path: bin_name, args, envs })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let _temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_npm_audit() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: false,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"audit\"]);\n    }\n\n    #[test]\n    fn test_npm_audit_fix() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: true,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"audit\", \"fix\"]);\n    }\n\n    #[test]\n    fn test_pnpm_audit_fix() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: true,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"audit\", \"--fix\"]);\n    }\n\n    #[test]\n    fn test_yarn1_audit() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: false,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"audit\"]);\n    }\n\n    #[test]\n    fn test_yarn1_audit_fix_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: true,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_yarn2_audit() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: false,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"npm\", \"audit\"]);\n    }\n\n    #[test]\n    fn test_yarn2_audit_fix_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: true,\n            json: false,\n            level: None,\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_audit_with_level_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: false,\n            json: false,\n            level: Some(\"high\"),\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"audit\", \"--audit-level\", \"high\"]);\n    }\n\n    #[test]\n    fn test_audit_with_level_yarn1() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: false,\n            json: false,\n            level: Some(\"high\"),\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"audit\", \"--level\", \"high\"]);\n    }\n\n    #[test]\n    fn test_audit_with_level_yarn2() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_audit_command(&AuditCommandOptions {\n            fix: false,\n            json: false,\n            level: Some(\"high\"),\n            production: false,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"npm\", \"audit\", \"--severity\", \"high\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/cache.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the cache command.\n#[derive(Debug)]\npub struct CacheCommandOptions<'a> {\n    pub subcommand: &'a str,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the cache command with the package manager.\n    /// Returns ExitStatus with success (0) if the command is not supported.\n    #[must_use]\n    pub async fn run_cache_command(\n        &self,\n        options: &CacheCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let Some(resolve_command) = self.resolve_cache_command(options) else {\n            // Command not supported, return success\n            return Ok(ExitStatus::default());\n        };\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the cache command.\n    /// Returns None if the command is not supported by the package manager.\n    #[must_use]\n    pub fn resolve_cache_command(\n        &self,\n        options: &CacheCommandOptions,\n    ) -> Option<ResolveCommandResult> {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n\n                match options.subcommand {\n                    \"dir\" | \"path\" => {\n                        args.push(\"store\".into());\n                        args.push(\"path\".into());\n                    }\n                    \"clean\" => {\n                        args.push(\"store\".into());\n                        args.push(\"prune\".into());\n                    }\n                    _ => {\n                        output::warn(&format!(\n                            \"pnpm cache subcommand '{}' not supported\",\n                            options.subcommand\n                        ));\n                        return None;\n                    }\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n\n                match options.subcommand {\n                    \"dir\" | \"path\" => {\n                        // npm uses 'config get cache' to get cache directory\n                        args.push(\"config\".into());\n                        args.push(\"get\".into());\n                        args.push(\"cache\".into());\n                    }\n                    \"clean\" => {\n                        args.push(\"cache\".into());\n                        args.push(\"clean\".into());\n                    }\n                    _ => {\n                        output::warn(&format!(\n                            \"npm cache subcommand '{}' not supported\",\n                            options.subcommand\n                        ));\n                        return None;\n                    }\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                match options.subcommand {\n                    \"dir\" | \"path\" => {\n                        if is_yarn1 {\n                            args.push(\"cache\".into());\n                            args.push(\"dir\".into());\n                        } else {\n                            args.push(\"config\".into());\n                            args.push(\"get\".into());\n                            args.push(\"cacheFolder\".into());\n                        }\n                    }\n                    \"clean\" => {\n                        args.push(\"cache\".into());\n                        args.push(\"clean\".into());\n                    }\n                    _ => {\n                        output::warn(&format!(\n                            \"yarn cache subcommand '{}' not supported\",\n                            options.subcommand\n                        ));\n                        return None;\n                    }\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        Some(ResolveCommandResult { bin_path: bin_name, args, envs })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_cache_dir() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"dir\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"store\", \"path\"]);\n    }\n\n    #[test]\n    fn test_npm_cache_dir() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"dir\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"config\", \"get\", \"cache\"]);\n    }\n\n    #[test]\n    fn test_yarn1_cache_dir() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"dir\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"cache\", \"dir\"]);\n    }\n\n    #[test]\n    fn test_yarn2_cache_dir() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"dir\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"config\", \"get\", \"cacheFolder\"]);\n    }\n\n    #[test]\n    fn test_pnpm_cache_clean() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"clean\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"store\", \"prune\"]);\n    }\n\n    #[test]\n    fn test_npm_cache_clean() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"clean\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"cache\", \"clean\"]);\n    }\n\n    #[test]\n    fn test_yarn1_cache_clean() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"clean\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"cache\", \"clean\"]);\n    }\n\n    #[test]\n    fn test_yarn2_cache_clean() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_cache_command(&CacheCommandOptions {\n            subcommand: \"clean\",\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"cache\", \"clean\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/config.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the config command.\n#[derive(Debug)]\npub struct ConfigCommandOptions<'a> {\n    pub subcommand: &'a str,\n    pub key: Option<&'a str>,\n    pub value: Option<&'a str>,\n    pub json: bool,\n    pub location: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the config command with the package manager.\n    #[must_use]\n    pub async fn run_config_command(\n        &self,\n        options: &ConfigCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_config_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the config command.\n    #[must_use]\n    pub fn resolve_config_command(&self, options: &ConfigCommandOptions) -> ResolveCommandResult {\n        let bin_name: String = self.client.to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                args.push(\"config\".into());\n                args.push(options.subcommand.to_string());\n\n                if let Some(key) = options.key {\n                    args.push(key.to_string());\n                }\n\n                if let Some(value) = options.value {\n                    args.push(value.to_string());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if let Some(location) = options.location {\n                    args.push(\"--location\".into());\n                    args.push(location.to_string());\n                }\n            }\n            PackageManagerType::Npm => {\n                args.push(\"config\".into());\n                args.push(options.subcommand.to_string());\n\n                if let Some(key) = options.key {\n                    args.push(key.to_string());\n                }\n\n                if let Some(value) = options.value {\n                    args.push(value.to_string());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if let Some(location) = options.location {\n                    args.push(\"--location\".into());\n                    args.push(location.to_string());\n                }\n            }\n            PackageManagerType::Yarn => {\n                args.push(\"config\".into());\n\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                // yarn@2+ uses 'unset' instead of 'delete', and no subcommand for 'list'\n                if options.subcommand == \"delete\" && !is_yarn1 {\n                    args.push(\"unset\".into());\n                } else if options.subcommand == \"list\" && !is_yarn1 {\n                    // yarn@2+: 'yarn config' with no subcommand lists all\n                    // Don't add 'list'\n                } else {\n                    args.push(options.subcommand.to_string());\n                }\n\n                if let Some(key) = options.key {\n                    args.push(key.to_string());\n                }\n\n                if let Some(value) = options.value {\n                    args.push(value.to_string());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                // Handle --location parameter\n                if let Some(location) = options.location {\n                    if !is_yarn1 {\n                        // yarn@2+: map 'global' to --home\n                        if location == \"global\" {\n                            args.push(\"--home\".into());\n                        }\n                    } else {\n                        // yarn@1: use --global for global location\n                        if location == \"global\" {\n                            args.push(\"--global\".into());\n                        } else {\n                            output::warn(\"yarn@1 does not support --location, ignoring flag\");\n                        }\n                    }\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_config_set() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\"]);\n    }\n\n    #[test]\n    fn test_npm_config_set() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\"]);\n    }\n\n    #[test]\n    fn test_config_set_with_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: true,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(\n            result.args,\n            vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\", \"--json\"]\n        );\n    }\n\n    #[test]\n    fn test_config_set_with_location_global() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: Some(\"global\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(\n            result.args,\n            vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\", \"--location\", \"global\"]\n        );\n    }\n\n    #[test]\n    fn test_yarn2_config_set_location_global() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: Some(\"global\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(\n            result.args,\n            vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\", \"--home\"]\n        );\n    }\n\n    #[test]\n    fn test_yarn1_config_set() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\"]);\n    }\n\n    #[test]\n    fn test_pnpm_config_set_global() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: Some(\"global\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(\n            result.args,\n            vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\", \"--location\", \"global\"]\n        );\n    }\n\n    #[test]\n    fn test_npm_config_set_global() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: Some(\"global\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\", \"--location\", \"global\"]\n        );\n    }\n\n    #[test]\n    fn test_yarn1_config_set_global() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"set\",\n            key: Some(\"registry\"),\n            value: Some(\"https://registry.npmjs.org\"),\n            json: false,\n            location: Some(\"global\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(\n            result.args,\n            vec![\"config\", \"set\", \"registry\", \"https://registry.npmjs.org\", \"--global\"]\n        );\n    }\n\n    #[test]\n    fn test_pnpm_config_get() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"get\",\n            key: Some(\"registry\"),\n            value: None,\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"config\", \"get\", \"registry\"]);\n    }\n\n    #[test]\n    fn test_npm_config_delete() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"delete\",\n            key: Some(\"registry\"),\n            value: None,\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"config\", \"delete\", \"registry\"]);\n    }\n\n    #[test]\n    fn test_yarn2_config_delete() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"delete\",\n            key: Some(\"registry\"),\n            value: None,\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"config\", \"unset\", \"registry\"]);\n    }\n\n    #[test]\n    fn test_yarn2_config_list() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_config_command(&ConfigCommandOptions {\n            subcommand: \"list\",\n            key: None,\n            value: None,\n            json: false,\n            location: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"config\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/dedupe.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the dedupe command.\n#[derive(Debug, Default)]\npub struct DedupeCommandOptions<'a> {\n    pub check: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the dedupe command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_dedupe_command(\n        &self,\n        options: &DedupeCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_dedupe_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the dedupe command.\n    #[must_use]\n    pub fn resolve_dedupe_command(&self, options: &DedupeCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"dedupe\".into());\n\n                // pnpm uses --check for dry-run\n                if options.check {\n                    args.push(\"--check\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                args.push(\"dedupe\".into());\n\n                // yarn@2+ supports --check\n                if options.check {\n                    args.push(\"--check\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"dedupe\".into());\n\n                if options.check {\n                    args.push(\"--dry-run\".into());\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_dedupe_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"dedupe\"]);\n    }\n\n    #[test]\n    fn test_pnpm_dedupe_check() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"dedupe\", \"--check\"]);\n    }\n\n    #[test]\n    fn test_npm_dedupe_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });\n        assert_eq!(result.args, vec![\"dedupe\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_dedupe_check() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });\n        assert_eq!(result.args, vec![\"dedupe\", \"--dry-run\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_yarn_dedupe_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });\n        assert_eq!(result.args, vec![\"dedupe\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_dedupe_check() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result =\n            pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });\n        assert_eq!(result.args, vec![\"dedupe\", \"--check\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/deprecate.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Options for the deprecate command.\n#[derive(Debug, Default)]\npub struct DeprecateCommandOptions<'a> {\n    pub package: &'a str,\n    pub message: &'a str,\n    pub otp: Option<&'a str>,\n    pub registry: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the deprecate command with the package manager.\n    #[must_use]\n    pub async fn run_deprecate_command(\n        &self,\n        options: &DeprecateCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_deprecate_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the deprecate command.\n    /// All package managers delegate to npm deprecate.\n    #[must_use]\n    pub fn resolve_deprecate_command(\n        &self,\n        options: &DeprecateCommandOptions,\n    ) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"deprecate\".into());\n        args.push(options.package.to_string());\n        args.push(options.message.to_string());\n\n        if let Some(otp_value) = options.otp {\n            args.push(\"--otp\".into());\n            args.push(otp_value.to_string());\n        }\n\n        if let Some(registry_value) = options.registry {\n            args.push(\"--registry\".into());\n            args.push(registry_value.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_deprecate_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_deprecate_command(&DeprecateCommandOptions {\n            package: \"my-package@1.0.0\",\n            message: \"This version is deprecated\",\n            otp: None,\n            registry: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\"deprecate\", \"my-package@1.0.0\", \"This version is deprecated\"]\n        );\n    }\n\n    #[test]\n    fn test_deprecate_with_otp() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_deprecate_command(&DeprecateCommandOptions {\n            package: \"my-package@1.0.0\",\n            message: \"Use v2 instead\",\n            otp: Some(\"123456\"),\n            registry: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\"deprecate\", \"my-package@1.0.0\", \"Use v2 instead\", \"--otp\", \"123456\"]\n        );\n    }\n\n    #[test]\n    fn test_deprecate_with_registry() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_deprecate_command(&DeprecateCommandOptions {\n            package: \"my-package\",\n            message: \"Deprecated\",\n            otp: None,\n            registry: Some(\"https://registry.npmjs.org\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\n                \"deprecate\",\n                \"my-package\",\n                \"Deprecated\",\n                \"--registry\",\n                \"https://registry.npmjs.org\"\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/dist_tag.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Subcommands for the dist-tag command.\n#[derive(Debug, Clone)]\npub enum DistTagSubcommand {\n    List { package: Option<String> },\n    Add { package_at_version: String, tag: String },\n    Rm { package: String, tag: String },\n}\n\n/// Options for the dist-tag command.\n#[derive(Debug)]\npub struct DistTagCommandOptions<'a> {\n    pub subcommand: DistTagSubcommand,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the dist-tag command with the package manager.\n    #[must_use]\n    pub async fn run_dist_tag_command(\n        &self,\n        options: &DistTagCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_dist_tag_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the dist-tag command.\n    /// All package managers support dist-tag.\n    #[must_use]\n    pub fn resolve_dist_tag_command(\n        &self,\n        options: &DistTagCommandOptions,\n    ) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Npm | PackageManagerType::Pnpm => {\n                bin_name = \"npm\".into();\n                args.push(\"dist-tag\".into());\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                if is_yarn1 {\n                    args.push(\"tag\".into());\n                } else {\n                    args.push(\"npm\".into());\n                    args.push(\"tag\".into());\n                }\n            }\n        }\n\n        match &options.subcommand {\n            DistTagSubcommand::List { package } => {\n                args.push(\"list\".into());\n                if let Some(pkg) = package {\n                    args.push(pkg.clone());\n                }\n            }\n            DistTagSubcommand::Add { package_at_version, tag } => {\n                args.push(\"add\".into());\n                args.push(package_at_version.clone());\n                args.push(tag.clone());\n            }\n            DistTagSubcommand::Rm { package, tag } => {\n                args.push(\"rm\".into());\n                args.push(package.clone());\n                args.push(tag.clone());\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let _temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_npm_dist_tag_list() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_dist_tag_command(&DistTagCommandOptions {\n            subcommand: DistTagSubcommand::List { package: Some(\"my-package\".into()) },\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"dist-tag\", \"list\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_pnpm_dist_tag_list() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_dist_tag_command(&DistTagCommandOptions {\n            subcommand: DistTagSubcommand::List { package: Some(\"my-package\".into()) },\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"dist-tag\", \"list\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_yarn1_dist_tag_list() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_dist_tag_command(&DistTagCommandOptions {\n            subcommand: DistTagSubcommand::List { package: Some(\"my-package\".into()) },\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"tag\", \"list\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_yarn2_dist_tag_list() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_dist_tag_command(&DistTagCommandOptions {\n            subcommand: DistTagSubcommand::List { package: Some(\"my-package\".into()) },\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"npm\", \"tag\", \"list\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_dist_tag_add() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_dist_tag_command(&DistTagCommandOptions {\n            subcommand: DistTagSubcommand::Add {\n                package_at_version: \"my-package@1.0.0\".into(),\n                tag: \"beta\".into(),\n            },\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"dist-tag\", \"add\", \"my-package@1.0.0\", \"beta\"]);\n    }\n\n    #[test]\n    fn test_dist_tag_rm() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_dist_tag_command(&DistTagCommandOptions {\n            subcommand: DistTagSubcommand::Rm { package: \"my-package\".into(), tag: \"beta\".into() },\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"dist-tag\", \"rm\", \"my-package\", \"beta\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/dlx.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the dlx command.\n#[derive(Debug, Default)]\npub struct DlxCommandOptions<'a> {\n    /// Additional packages to install before running the command\n    pub packages: &'a [String],\n    /// The package to execute (first positional arg)\n    pub package_spec: &'a str,\n    /// Arguments to pass to the executed command\n    pub args: &'a [String],\n    /// Execute in shell mode\n    pub shell_mode: bool,\n    /// Suppress output\n    pub silent: bool,\n}\n\nimpl PackageManager {\n    /// Run the dlx command with the package manager.\n    pub async fn run_dlx_command(\n        &self,\n        options: &DlxCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_dlx_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the dlx command for the detected package manager.\n    #[must_use]\n    pub fn resolve_dlx_command(&self, options: &DlxCommandOptions) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n\n        match self.client {\n            PackageManagerType::Pnpm => self.resolve_pnpm_dlx(options, envs),\n            PackageManagerType::Npm => self.resolve_npm_dlx(options, envs),\n            PackageManagerType::Yarn => {\n                if self.version.starts_with(\"1.\") {\n                    // Yarn 1.x doesn't have dlx, fall back to npx\n                    self.resolve_npx_fallback(options, envs)\n                } else {\n                    self.resolve_yarn_dlx(options, envs)\n                }\n            }\n        }\n    }\n\n    fn resolve_pnpm_dlx(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        let mut args = Vec::new();\n\n        // Add --package flags before dlx\n        for pkg in options.packages {\n            args.push(\"--package\".into());\n            args.push(pkg.clone());\n        }\n\n        args.push(\"dlx\".into());\n\n        // Add shell mode flag\n        if options.shell_mode {\n            args.push(\"-c\".into());\n        }\n\n        // Add silent flag\n        if options.silent {\n            args.push(\"--silent\".into());\n        }\n\n        // Add package spec\n        args.push(options.package_spec.into());\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n\n        ResolveCommandResult { bin_path: \"pnpm\".into(), args, envs }\n    }\n\n    fn resolve_npm_dlx(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        let mut args = vec![\"exec\".into()];\n\n        // Add package flags for additional packages\n        for pkg in options.packages {\n            args.push(format!(\"--package={}\", pkg));\n        }\n\n        // When using additional packages or version specifiers, npm exec requires explicit\n        // --package flags. For example, `npm exec typescript@5.5.4 -- tsc` doesn't work;\n        // we need `npm exec --package=typescript@5.5.4 -- typescript`.\n        // Shell mode uses the package_spec as the shell command, so skip this in that case.\n        if !options.shell_mode\n            && (!options.packages.is_empty() || options.package_spec.contains('@'))\n        {\n            args.push(format!(\"--package={}\", options.package_spec));\n        }\n\n        // Always add --yes to auto-confirm prompts (align with pnpm behavior)\n        args.push(\"--yes\".into());\n\n        // Add silent flag\n        if options.silent {\n            args.push(\"--loglevel\".into());\n            args.push(\"silent\".into());\n        }\n\n        if options.shell_mode {\n            args.push(\"-c\".into());\n            args.push(build_shell_command(options.package_spec, options.args));\n        } else {\n            // Add separator and command\n            args.push(\"--\".into());\n\n            // When --package flag was added above (for version specifiers or additional packages),\n            // we need to extract just the command name without the version suffix.\n            // e.g., \"typescript@5.5.4\" → command is \"typescript\" (version is in --package flag)\n            // Otherwise, use package_spec directly as the command.\n            let command = if options.packages.is_empty() && !options.package_spec.contains('@') {\n                options.package_spec.to_string()\n            } else {\n                extract_command_from_spec(options.package_spec)\n            };\n            args.push(command);\n\n            // Add command arguments\n            args.extend(options.args.iter().cloned());\n        }\n\n        ResolveCommandResult { bin_path: \"npm\".into(), args, envs }\n    }\n\n    fn resolve_yarn_dlx(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        let mut args = vec![\"dlx\".into()];\n\n        // Add package flags\n        for pkg in options.packages {\n            args.push(\"-p\".into());\n            args.push(pkg.clone());\n        }\n\n        // Add quiet flag for silent mode\n        if options.silent {\n            args.push(\"--quiet\".into());\n        }\n\n        // Warn about unsupported shell mode\n        if options.shell_mode {\n            output::warn(\"yarn dlx does not support shell mode (-c)\");\n        }\n\n        // Add package spec\n        args.push(options.package_spec.into());\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n\n        ResolveCommandResult { bin_path: \"yarn\".into(), args, envs }\n    }\n\n    fn resolve_npx_fallback(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        output::note(\"yarn@1 does not have dlx command, falling back to npx\");\n\n        let args = build_npx_args(options);\n        ResolveCommandResult { bin_path: \"npx\".into(), args, envs }\n    }\n}\n\n/// Build npx command-line arguments from dlx options.\n///\n/// Used both by the yarn@1 fallback (in `resolve_npx_fallback`) and by the\n/// no-package.json fallback in `vite_global_cli`.\npub fn build_npx_args(options: &DlxCommandOptions<'_>) -> Vec<String> {\n    let mut args = Vec::new();\n\n    // Add package flags\n    for pkg in options.packages {\n        args.push(\"--package\".into());\n        args.push(pkg.clone());\n    }\n\n    // Always add --yes to auto-confirm prompts (align with pnpm behavior)\n    args.push(\"--yes\".into());\n\n    // Add quiet flag for silent mode\n    if options.silent {\n        args.push(\"--quiet\".into());\n    }\n\n    if options.shell_mode {\n        args.push(\"-c\".into());\n        args.push(build_shell_command(options.package_spec, options.args));\n    } else {\n        // Add package spec\n        args.push(options.package_spec.into());\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n    }\n\n    args\n}\n\nfn build_shell_command(package_spec: &str, args: &[String]) -> String {\n    if args.is_empty() {\n        package_spec.to_string()\n    } else {\n        let mut command = String::from(package_spec);\n        for arg in args {\n            command.push(' ');\n            command.push_str(arg);\n        }\n        command\n    }\n}\n\n/// Extract command name from package spec\n/// e.g., \"create-vue@3.10.0\" -> \"create-vue\"\nfn extract_command_from_spec(spec: &str) -> String {\n    // Handle scoped packages: @scope/pkg@version -> pkg\n    if spec.starts_with('@') {\n        // Find the slash that separates scope from package name\n        if let Some(slash_pos) = spec.find('/') {\n            let after_slash = &spec[slash_pos + 1..];\n            // Find the version separator (@ after the package name)\n            if let Some(at_pos) = after_slash.find('@') {\n                return after_slash[..at_pos].to_string();\n            }\n            return after_slash.to_string();\n        }\n    }\n\n    // Non-scoped: pkg@version -> pkg\n    if let Some(at_pos) = spec.find('@') {\n        return spec[..at_pos].to_string();\n    }\n\n    spec.to_string()\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_extract_command_from_spec() {\n        assert_eq!(extract_command_from_spec(\"create-vue\"), \"create-vue\");\n        assert_eq!(extract_command_from_spec(\"create-vue@3.10.0\"), \"create-vue\");\n        assert_eq!(extract_command_from_spec(\"typescript@5.5.4\"), \"typescript\");\n        assert_eq!(extract_command_from_spec(\"@vue/cli\"), \"cli\");\n        assert_eq!(extract_command_from_spec(\"@vue/cli@5.0.0\"), \"cli\");\n        assert_eq!(extract_command_from_spec(\"@pnpm/meta-updater\"), \"meta-updater\");\n        assert_eq!(extract_command_from_spec(\"@pnpm/meta-updater@1.0.0\"), \"meta-updater\");\n    }\n\n    #[test]\n    fn test_pnpm_dlx_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"dlx\", \"create-vue\", \"my-app\"]);\n    }\n\n    #[test]\n    fn test_pnpm_dlx_with_version() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"typescript@5.5.4\",\n            args: &[\"tsc\".into(), \"--version\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"dlx\", \"typescript@5.5.4\", \"tsc\", \"--version\"]);\n    }\n\n    #[test]\n    fn test_pnpm_dlx_with_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[\"yo\".into(), \"generator-webapp\".into()],\n            package_spec: \"yo\",\n            args: &[\"webapp\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(\n            result.args,\n            vec![\"--package\", \"yo\", \"--package\", \"generator-webapp\", \"dlx\", \"yo\", \"webapp\"]\n        );\n    }\n\n    #[test]\n    fn test_pnpm_dlx_with_shell_mode() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[\"cowsay\".into()],\n            package_spec: \"echo hello | cowsay\",\n            args: &[],\n            shell_mode: true,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert!(result.args.contains(&\"-c\".to_string()));\n        assert!(result.args.contains(&\"--package\".to_string()));\n    }\n\n    #[test]\n    fn test_pnpm_dlx_with_silent() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: true,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert!(result.args.contains(&\"--silent\".to_string()));\n    }\n\n    #[test]\n    fn test_npm_exec_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npm\");\n        // --yes is always added to auto-confirm prompts\n        assert_eq!(result.args, vec![\"exec\", \"--yes\", \"--\", \"create-vue\", \"my-app\"]);\n    }\n\n    #[test]\n    fn test_npm_exec_with_version() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"typescript@5.5.4\",\n            args: &[\"tsc\".into(), \"--version\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npm\");\n        // --yes is always added to auto-confirm prompts\n        assert_eq!(\n            result.args,\n            vec![\n                \"exec\",\n                \"--package=typescript@5.5.4\",\n                \"--yes\",\n                \"--\",\n                \"typescript\",\n                \"tsc\",\n                \"--version\"\n            ]\n        );\n    }\n\n    #[test]\n    fn test_npm_exec_with_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[\"yo\".into(), \"generator-webapp\".into()],\n            package_spec: \"yo\",\n            args: &[\"webapp\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npm\");\n        // --yes is always added to auto-confirm prompts\n        assert_eq!(\n            result.args,\n            vec![\n                \"exec\",\n                \"--package=yo\",\n                \"--package=generator-webapp\",\n                \"--package=yo\",\n                \"--yes\",\n                \"--\",\n                \"yo\",\n                \"webapp\"\n            ]\n        );\n    }\n\n    #[test]\n    fn test_npm_exec_with_silent() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: true,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npm\");\n        assert!(result.args.contains(&\"--loglevel\".to_string()));\n        assert!(result.args.contains(&\"silent\".to_string()));\n        // --yes is always added to auto-confirm prompts\n        assert!(result.args.contains(&\"--yes\".to_string()));\n    }\n\n    #[test]\n    fn test_npm_exec_shell_mode_places_command_after_flag() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[\"cowsay\".into(), \"lolcatjs\".into()],\n            package_spec: \"echo hello | cowsay | lolcatjs\",\n            args: &[],\n            shell_mode: true,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(\n            result.args,\n            vec![\n                \"exec\",\n                \"--package=cowsay\",\n                \"--package=lolcatjs\",\n                \"--yes\",\n                \"-c\",\n                \"echo hello | cowsay | lolcatjs\"\n            ]\n        );\n    }\n\n    #[test]\n    fn test_npm_exec_shell_mode_with_additional_args() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"echo\",\n            args: &[\"hello world\".into()],\n            shell_mode: true,\n            silent: true,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(\n            result.args,\n            vec![\"exec\", \"--yes\", \"--loglevel\", \"silent\", \"-c\", \"echo hello world\"]\n        );\n    }\n\n    #[test]\n    fn test_npm_exec_scoped_package_with_version() {\n        // Scoped packages with version need --package flag and extracted command name\n        // e.g., \"@vue/cli@5.0.0\" -> --package=@vue/cli@5.0.0 and command \"cli\"\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"@vue/cli@5.0.0\",\n            args: &[\"create\".into(), \"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\"exec\", \"--package=@vue/cli@5.0.0\", \"--yes\", \"--\", \"cli\", \"create\", \"my-app\"]\n        );\n    }\n\n    #[test]\n    fn test_npm_exec_scoped_package_without_version() {\n        // Scoped packages contain '@' in their name, so the current logic treats them\n        // the same as versioned packages (adds --package flag and extracts command name).\n        // e.g., \"@vue/cli\" -> --package=@vue/cli and command \"cli\"\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"@vue/cli\",\n            args: &[\"create\".into(), \"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\"exec\", \"--package=@vue/cli\", \"--yes\", \"--\", \"cli\", \"create\", \"my-app\"]\n        );\n    }\n\n    #[test]\n    fn test_npm_exec_version_requires_package_flag_and_extracted_command() {\n        // This test documents the key behavior: when package_spec contains '@' for version,\n        // npm exec needs BOTH:\n        // 1. --package=<full-spec> to specify the version\n        // 2. The command name (without version) after -- separator\n        // Without this, `npm exec create-vue@3.10.0` would fail to find the command\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue@3.10.0\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n\n        // Verify --package flag contains full spec with version\n        assert!(result.args.contains(&\"--package=create-vue@3.10.0\".to_string()));\n\n        // Verify command after -- is just the name without version\n        let separator_pos = result.args.iter().position(|a| a == \"--\").unwrap();\n        assert_eq!(result.args[separator_pos + 1], \"create-vue\");\n    }\n\n    #[test]\n    fn test_yarn_v1_fallback_to_npx() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.19\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npx\");\n        // --yes is always added to auto-confirm prompts\n        assert_eq!(result.args, vec![\"--yes\", \"create-vue\", \"my-app\"]);\n    }\n\n    #[test]\n    fn test_yarn_v1_fallback_with_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.19\");\n        let options = DlxCommandOptions {\n            packages: &[\"yo\".into()],\n            package_spec: \"yo\",\n            args: &[\"webapp\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"npx\");\n        // --yes is always added to auto-confirm prompts\n        assert_eq!(result.args, vec![\"--package\", \"yo\", \"--yes\", \"yo\", \"webapp\"]);\n    }\n\n    #[test]\n    fn test_yarn_v1_fallback_shell_mode_places_command_after_flag() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.19\");\n        let options = DlxCommandOptions {\n            packages: &[\"cowsay\".into()],\n            package_spec: \"echo hello | cowsay\",\n            args: &[],\n            shell_mode: true,\n            silent: true,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(\n            result.args,\n            vec![\"--package\", \"cowsay\", \"--yes\", \"--quiet\", \"-c\", \"echo hello | cowsay\"]\n        );\n    }\n\n    #[test]\n    fn test_yarn_v2_dlx_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"dlx\", \"create-vue\", \"my-app\"]);\n    }\n\n    #[test]\n    fn test_yarn_v2_dlx_with_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[\"yo\".into(), \"generator-webapp\".into()],\n            package_spec: \"yo\",\n            args: &[\"webapp\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"dlx\", \"-p\", \"yo\", \"-p\", \"generator-webapp\", \"yo\", \"webapp\"]);\n    }\n\n    #[test]\n    fn test_yarn_v2_dlx_with_quiet() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: true,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"yarn\");\n        assert!(result.args.contains(&\"--quiet\".to_string()));\n    }\n\n    #[test]\n    fn test_yarn_v3_dlx() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"3.6.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"create-vue\",\n            args: &[\"my-app\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"dlx\", \"create-vue\", \"my-app\"]);\n    }\n\n    #[test]\n    fn test_yarn_v2_dlx_with_version() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let options = DlxCommandOptions {\n            packages: &[],\n            package_spec: \"typescript@5.5.4\",\n            args: &[\"tsc\".into(), \"--version\".into()],\n            shell_mode: false,\n            silent: false,\n        };\n        let result = pm.resolve_dlx_command(&options);\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"dlx\", \"typescript@5.5.4\", \"tsc\", \"--version\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/fund.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Options for the fund command.\n#[derive(Debug, Default)]\npub struct FundCommandOptions<'a> {\n    pub json: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the fund command with the package manager.\n    #[must_use]\n    pub async fn run_fund_command(\n        &self,\n        options: &FundCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_fund_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the fund command.\n    /// All package managers delegate to npm fund.\n    #[must_use]\n    pub fn resolve_fund_command(&self, options: &FundCommandOptions) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"fund\".into());\n\n        if options.json {\n            args.push(\"--json\".into());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_fund_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_fund_command(&FundCommandOptions { json: false, pass_through_args: None });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"fund\"]);\n    }\n\n    #[test]\n    fn test_fund_with_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_fund_command(&FundCommandOptions { json: true, pass_through_args: None });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"fund\", \"--json\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/install.rs",
    "content": "use std::{collections::HashMap, iter, process::ExitStatus};\n\nuse tracing::warn;\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Install command options.\n#[derive(Debug, Default)]\npub struct InstallCommandOptions<'a> {\n    /// Do not install devDependencies\n    pub prod: bool,\n    /// Only install devDependencies\n    pub dev: bool,\n    /// Do not install optionalDependencies\n    pub no_optional: bool,\n    /// Fail if lockfile needs to be updated (CI mode)\n    pub frozen_lockfile: bool,\n    /// Allow lockfile updates (opposite of --frozen-lockfile, takes higher priority)\n    pub no_frozen_lockfile: bool,\n    /// Only update lockfile, don't install\n    pub lockfile_only: bool,\n    /// Use cached packages when available\n    pub prefer_offline: bool,\n    /// Only use packages already in cache\n    pub offline: bool,\n    /// Force reinstall all dependencies\n    pub force: bool,\n    /// Do not run lifecycle scripts\n    pub ignore_scripts: bool,\n    /// Don't read or generate lockfile\n    pub no_lockfile: bool,\n    /// Fix broken lockfile entries (pnpm and yarn@2+ only)\n    pub fix_lockfile: bool,\n    /// Create flat node_modules (pnpm only)\n    pub shamefully_hoist: bool,\n    /// Re-run resolution for peer dependency analysis (pnpm only)\n    pub resolution_only: bool,\n    /// Suppress output (silent mode)\n    pub silent: bool,\n    /// Filter packages in monorepo\n    pub filters: Option<&'a [String]>,\n    /// Install in workspace root only\n    pub workspace_root: bool,\n    /// Additional arguments to pass through to the package manager\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the install command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_install_command(\n        &self,\n        options: &InstallCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_install_command_with_options(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the install command with options.\n    #[must_use]\n    pub fn resolve_install_command_with_options(\n        &self,\n        options: &InstallCommandOptions,\n    ) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"install\".into());\n\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n                // --no-frozen-lockfile takes higher priority over --frozen-lockfile\n                if options.no_frozen_lockfile {\n                    args.push(\"--no-frozen-lockfile\".into());\n                } else if options.frozen_lockfile {\n                    args.push(\"--frozen-lockfile\".into());\n                }\n                if options.lockfile_only {\n                    args.push(\"--lockfile-only\".into());\n                }\n                if options.prefer_offline {\n                    args.push(\"--prefer-offline\".into());\n                }\n                if options.offline {\n                    args.push(\"--offline\".into());\n                }\n                if options.force {\n                    args.push(\"--force\".into());\n                }\n                if options.ignore_scripts {\n                    args.push(\"--ignore-scripts\".into());\n                }\n                if options.no_lockfile {\n                    args.push(\"--no-lockfile\".into());\n                }\n                if options.fix_lockfile {\n                    args.push(\"--fix-lockfile\".into());\n                }\n                if options.shamefully_hoist {\n                    args.push(\"--shamefully-hoist\".into());\n                }\n                if options.resolution_only {\n                    args.push(\"--resolution-only\".into());\n                }\n                if options.silent {\n                    args.push(\"--silent\".into());\n                }\n                if options.workspace_root {\n                    args.push(\"-w\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                let is_berry = self.is_yarn_berry();\n\n                // yarn@2+ filter needs workspaces foreach\n                if is_berry && options.filters.is_some() {\n                    args.push(\"workspaces\".into());\n                    args.push(\"foreach\".into());\n                    args.push(\"-A\".into());\n                    if let Some(filters) = options.filters {\n                        for filter in filters {\n                            args.push(\"--include\".into());\n                            args.push(filter.clone());\n                        }\n                    }\n                }\n                args.push(\"install\".into());\n\n                if is_berry {\n                    // yarn@2+ (Berry)\n                    // --no-frozen-lockfile takes higher priority over --frozen-lockfile\n                    if options.no_frozen_lockfile {\n                        args.push(\"--no-immutable\".into());\n                    } else if options.frozen_lockfile {\n                        args.push(\"--immutable\".into());\n                    }\n                    if options.lockfile_only {\n                        args.push(\"--mode\".into());\n                        args.push(\"update-lockfile\".into());\n                        if options.ignore_scripts {\n                            warn!(\n                                \"yarn@2+ --mode can only be specified once; --lockfile-only takes priority over --ignore-scripts\"\n                            );\n                        }\n                    } else if options.ignore_scripts {\n                        args.push(\"--mode\".into());\n                        args.push(\"skip-build\".into());\n                    }\n                    if options.fix_lockfile {\n                        args.push(\"--refresh-lockfile\".into());\n                    }\n                    if options.silent {\n                        warn!(\n                            \"yarn@2+ does not support --silent, use YARN_ENABLE_PROGRESS=false instead\"\n                        );\n                    }\n                    if options.prod {\n                        warn!(\"yarn@2+ requires configuration in .yarnrc.yml for --prod behavior\");\n                    }\n                    if options.resolution_only {\n                        warn!(\"yarn@2+ does not support --resolution-only\");\n                    }\n                } else {\n                    // yarn@1 (Classic)\n                    if options.prod {\n                        args.push(\"--production\".into());\n                    }\n                    if options.no_optional {\n                        args.push(\"--ignore-optional\".into());\n                    }\n                    // --no-frozen-lockfile takes higher priority over --frozen-lockfile\n                    if options.no_frozen_lockfile {\n                        args.push(\"--no-frozen-lockfile\".into());\n                    } else if options.frozen_lockfile {\n                        args.push(\"--frozen-lockfile\".into());\n                    }\n                    if options.prefer_offline {\n                        args.push(\"--prefer-offline\".into());\n                    }\n                    if options.offline {\n                        args.push(\"--offline\".into());\n                    }\n                    if options.force {\n                        args.push(\"--force\".into());\n                    }\n                    if options.ignore_scripts {\n                        args.push(\"--ignore-scripts\".into());\n                    }\n                    if options.silent {\n                        args.push(\"--silent\".into());\n                    }\n                    if options.no_lockfile {\n                        args.push(\"--no-lockfile\".into());\n                    }\n                    if options.fix_lockfile {\n                        warn!(\"yarn@1 does not support --fix-lockfile\");\n                    }\n                    if options.resolution_only {\n                        warn!(\"yarn@1 does not support --resolution-only\");\n                    }\n                    if options.workspace_root {\n                        args.push(\"-W\".into());\n                    }\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                // npm: Use `npm ci` for frozen-lockfile, but --no-frozen-lockfile takes priority\n                let use_ci = options.frozen_lockfile && !options.no_frozen_lockfile;\n                if use_ci {\n                    args.push(\"ci\".into());\n                } else {\n                    args.push(\"install\".into());\n                }\n\n                if options.prod {\n                    args.push(\"--omit=dev\".into());\n                }\n                if options.dev && !use_ci {\n                    args.push(\"--include=dev\".into());\n                    args.push(\"--omit=prod\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--omit=optional\".into());\n                }\n                if options.lockfile_only && !use_ci {\n                    args.push(\"--package-lock-only\".into());\n                }\n                if options.prefer_offline {\n                    args.push(\"--prefer-offline\".into());\n                }\n                if options.offline {\n                    args.push(\"--offline\".into());\n                }\n                if options.force && !use_ci {\n                    args.push(\"--force\".into());\n                }\n                if options.ignore_scripts {\n                    args.push(\"--ignore-scripts\".into());\n                }\n                if options.no_lockfile && !use_ci {\n                    args.push(\"--no-package-lock\".into());\n                }\n                if options.fix_lockfile {\n                    warn!(\"npm does not support --fix-lockfile\");\n                }\n                if options.resolution_only {\n                    warn!(\"npm does not support --resolution-only\");\n                }\n                if options.silent {\n                    args.push(\"--loglevel\".into());\n                    args.push(\"silent\".into());\n                }\n                if options.workspace_root {\n                    args.push(\"--include-workspace-root\".into());\n                }\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n            }\n        }\n\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n\n    /// Resolve the install command (legacy - passes args directly).\n    pub fn resolve_install_command(&self, args: &Vec<String>) -> ResolveCommandResult {\n        ResolveCommandResult {\n            bin_path: self.bin_name.to_string(),\n            args: iter::once(\"install\")\n                .chain(args.iter().map(String::as_str))\n                .map(String::from)\n                .collect(),\n            envs: HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]),\n        }\n    }\n\n    /// Check if yarn version is Berry (v2+)\n    fn is_yarn_berry(&self) -> bool {\n        !self.version.starts_with(\"1.\")\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_basic_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default());\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"install\"]);\n    }\n\n    #[test]\n    fn test_pnpm_prod_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            prod: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--prod\"]);\n    }\n\n    #[test]\n    fn test_pnpm_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--frozen-lockfile\"]);\n    }\n\n    #[test]\n    fn test_pnpm_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"install\"]);\n    }\n\n    #[test]\n    fn test_pnpm_fix_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            fix_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--fix-lockfile\"]);\n    }\n\n    #[test]\n    fn test_pnpm_resolution_only() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            resolution_only: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--resolution-only\"]);\n    }\n\n    #[test]\n    fn test_pnpm_shamefully_hoist() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            shamefully_hoist: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--shamefully-hoist\"]);\n    }\n\n    #[test]\n    fn test_npm_basic_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default());\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"install\"]);\n    }\n\n    #[test]\n    fn test_npm_frozen_lockfile_uses_ci() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"ci\"]);\n    }\n\n    #[test]\n    fn test_npm_prod_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            prod: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--omit=dev\"]);\n    }\n\n    #[test]\n    fn test_npm_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--workspace\", \"app\"]);\n    }\n\n    #[test]\n    fn test_yarn_classic_basic_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default());\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"install\"]);\n    }\n\n    #[test]\n    fn test_yarn_classic_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--frozen-lockfile\"]);\n    }\n\n    #[test]\n    fn test_yarn_classic_prod_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            prod: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--production\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_basic_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions::default());\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"install\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--immutable\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_fix_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            fix_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--refresh-lockfile\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_ignore_scripts() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            ignore_scripts: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--mode\", \"skip-build\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_lockfile_only_takes_priority_over_ignore_scripts() {\n        // yarn@2+ --mode can only be specified once, lockfile_only should take priority\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            lockfile_only: true,\n            ignore_scripts: true,\n            ..Default::default()\n        });\n        // Only update-lockfile should be added, not skip-build\n        assert_eq!(result.args, vec![\"install\", \"--mode\", \"update-lockfile\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"workspaces\", \"foreach\", \"-A\", \"--include\", \"app\", \"install\"]);\n    }\n\n    #[test]\n    fn test_pnpm_all_options() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let pass_through = vec![\"--use-stderr\".to_string()];\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            prod: true,\n            no_optional: true,\n            prefer_offline: true,\n            ignore_scripts: true,\n            filters: Some(&filters),\n            workspace_root: true,\n            pass_through_args: Some(&pass_through),\n            ..Default::default()\n        });\n        assert_eq!(\n            result.args,\n            vec![\n                \"--filter\",\n                \"app\",\n                \"install\",\n                \"--prod\",\n                \"--no-optional\",\n                \"--prefer-offline\",\n                \"--ignore-scripts\",\n                \"-w\",\n                \"--use-stderr\"\n            ]\n        );\n    }\n\n    #[test]\n    fn test_pnpm_silent() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            silent: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--silent\"]);\n    }\n\n    #[test]\n    fn test_yarn_classic_silent() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            silent: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--silent\"]);\n    }\n\n    #[test]\n    fn test_npm_silent() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            silent: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--loglevel\", \"silent\"]);\n    }\n\n    #[test]\n    fn test_pnpm_no_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--no-frozen-lockfile\"]);\n    }\n\n    #[test]\n    fn test_pnpm_no_frozen_lockfile_overrides_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        // When both are set, --no-frozen-lockfile takes priority\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--no-frozen-lockfile\"]);\n    }\n\n    #[test]\n    fn test_yarn_classic_no_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--no-frozen-lockfile\"]);\n    }\n\n    #[test]\n    fn test_yarn_classic_no_frozen_lockfile_overrides_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--no-frozen-lockfile\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_no_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--no-immutable\"]);\n    }\n\n    #[test]\n    fn test_yarn_berry_no_frozen_lockfile_overrides_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\", \"--no-immutable\"]);\n    }\n\n    #[test]\n    fn test_npm_no_frozen_lockfile_uses_install() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        // --no-frozen-lockfile means use `npm install` instead of `npm ci`\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\"]);\n    }\n\n    #[test]\n    fn test_npm_no_frozen_lockfile_overrides_frozen_lockfile() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        // When both are set, --no-frozen-lockfile takes priority (use install, not ci)\n        let result = pm.resolve_install_command_with_options(&InstallCommandOptions {\n            frozen_lockfile: true,\n            no_frozen_lockfile: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"install\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/link.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the link command.\n#[derive(Debug, Default)]\npub struct LinkCommandOptions<'a> {\n    pub package: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the link command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_link_command(\n        &self,\n        options: &LinkCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_link_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the link command.\n    #[must_use]\n    pub fn resolve_link_command(&self, options: &LinkCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"link\".into());\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                args.push(\"link\".into());\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"link\".into());\n            }\n        }\n\n        // Add package/directory if specified\n        if let Some(package) = options.package {\n            args.push(package.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_link_no_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"link\"]);\n    }\n\n    #[test]\n    fn test_pnpm_link_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions {\n            package: Some(\"react\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"link\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_link_directory() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions {\n            package: Some(\"./packages/utils\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"link\", \"./packages/utils\"]);\n    }\n\n    #[test]\n    fn test_pnpm_link_absolute_directory() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions {\n            package: Some(\"/absolute/path/to/package\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"link\", \"/absolute/path/to/package\"]);\n    }\n\n    #[test]\n    fn test_yarn_link_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"link\"]);\n    }\n\n    #[test]\n    fn test_yarn_link_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions {\n            package: Some(\"react\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"link\", \"react\"]);\n    }\n\n    #[test]\n    fn test_npm_link_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"link\"]);\n    }\n\n    #[test]\n    fn test_npm_link_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_link_command(&LinkCommandOptions {\n            package: Some(\"react\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"link\", \"react\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/list.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the list command.\n#[derive(Debug, Default)]\npub struct ListCommandOptions<'a> {\n    pub pattern: Option<&'a str>,\n    pub depth: Option<u32>,\n    pub json: bool,\n    pub long: bool,\n    pub parseable: bool,\n    pub prod: bool,\n    pub dev: bool,\n    pub no_optional: bool,\n    pub exclude_peers: bool,\n    pub only_projects: bool,\n    pub find_by: Option<&'a str>,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub global: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the list command with the package manager.\n    /// Returns ExitStatus with success (0) if the command is not supported.\n    #[must_use]\n    pub async fn run_list_command(\n        &self,\n        options: &ListCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let Some(resolve_command) = self.resolve_list_command(options) else {\n            // Command not supported, return success\n            return Ok(ExitStatus::default());\n        };\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the list command.\n    /// Returns None if the command is not supported by the package manager.\n    #[must_use]\n    pub fn resolve_list_command(\n        &self,\n        options: &ListCommandOptions,\n    ) -> Option<ResolveCommandResult> {\n        // yarn@2+ does not support list command\n        if self.client == PackageManagerType::Yarn && !self.version.starts_with(\"1.\") {\n            output::warn(\"yarn@2+ does not support 'list' command\");\n            return None;\n        }\n\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        // Global packages should use npm cli only (since global installs use npm)\n        let bin_name: String;\n        if options.global {\n            bin_name = \"npm\".into();\n            Self::format_npm_list_args(&mut args, options);\n            args.push(\"-g\".into());\n\n            // Add pass-through args\n            if let Some(pass_through_args) = options.pass_through_args {\n                args.extend_from_slice(pass_through_args);\n            }\n\n            return Some(ResolveCommandResult { bin_path: bin_name, args, envs });\n        }\n\n        bin_name = self.client.to_string();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                args.push(\"list\".into());\n\n                if let Some(pattern) = options.pattern {\n                    args.push(pattern.to_string());\n                }\n\n                if let Some(depth) = options.depth {\n                    args.push(\"--depth\".into());\n                    args.push(depth.to_string());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if options.long {\n                    args.push(\"--long\".into());\n                }\n\n                if options.parseable {\n                    args.push(\"--parseable\".into());\n                }\n\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n\n                if options.exclude_peers {\n                    args.push(\"--exclude-peers\".into());\n                }\n\n                if options.only_projects {\n                    args.push(\"--only-projects\".into());\n                }\n\n                if let Some(find_by) = options.find_by {\n                    args.push(\"--find-by\".into());\n                    args.push(find_by.to_string());\n                }\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                Self::format_npm_list_args(&mut args, options);\n            }\n            PackageManagerType::Yarn => {\n                // yarn@1 only (yarn@2+ already filtered out earlier)\n                args.push(\"list\".into());\n\n                if let Some(pattern) = options.pattern {\n                    args.push(pattern.to_string());\n                }\n\n                if let Some(depth) = options.depth {\n                    args.push(\"--depth\".into());\n                    args.push(depth.to_string());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if options.prod {\n                    output::warn(\"yarn@1 does not support --prod, ignoring --prod flag\");\n                }\n\n                if options.dev {\n                    output::warn(\"yarn@1 does not support --dev, ignoring --dev flag\");\n                }\n\n                if options.no_optional {\n                    output::warn(\n                        \"yarn@1 does not support --no-optional, ignoring --no-optional flag\",\n                    );\n                }\n\n                if options.exclude_peers {\n                    output::warn(\"yarn@1 does not support --exclude-peers, ignoring flag\");\n                }\n\n                if options.only_projects {\n                    output::warn(\"yarn@1 does not support --only-projects, ignoring flag\");\n                }\n\n                if options.find_by.is_some() {\n                    output::warn(\"yarn@1 does not support --find-by, ignoring flag\");\n                }\n\n                if options.recursive {\n                    output::warn(\"yarn@1 does not support --recursive, ignoring --recursive flag\");\n                }\n\n                // Check for filters (not supported by yarn@1)\n                if let Some(filters) = options.filters {\n                    if !filters.is_empty() {\n                        output::warn(\"yarn@1 does not support --filter, ignoring --filter flag\");\n                    }\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        Some(ResolveCommandResult { bin_path: bin_name, args, envs })\n    }\n\n    fn format_npm_list_args(args: &mut Vec<String>, options: &ListCommandOptions) {\n        args.push(\"list\".into());\n\n        if let Some(pattern) = options.pattern {\n            args.push(pattern.to_string());\n        }\n\n        if let Some(depth) = options.depth {\n            args.push(\"--depth\".into());\n            args.push(depth.to_string());\n        }\n\n        if options.json {\n            args.push(\"--json\".into());\n        }\n\n        if options.long {\n            args.push(\"--long\".into());\n        }\n\n        if options.parseable {\n            args.push(\"--parseable\".into());\n        }\n\n        if options.prod {\n            args.push(\"--include\".into());\n            args.push(\"prod\".into());\n            args.push(\"--include\".into());\n            args.push(\"peer\".into());\n        }\n\n        if options.dev {\n            args.push(\"--include\".into());\n            args.push(\"dev\".into());\n        }\n\n        if options.no_optional {\n            args.push(\"--omit\".into());\n            args.push(\"optional\".into());\n        }\n\n        if options.exclude_peers {\n            args.push(\"--omit\".into());\n            args.push(\"peer\".into());\n        }\n\n        if options.only_projects {\n            output::warn(\"--only-projects not supported by npm, ignoring flag\");\n        }\n\n        if options.find_by.is_some() {\n            output::warn(\"--find-by not supported by npm, ignoring flag\");\n        }\n\n        if options.recursive {\n            args.push(\"--workspaces\".into());\n        }\n\n        // npm: --workspace comes after command (maps from --filter)\n        if let Some(filters) = options.filters {\n            for filter in filters {\n                args.push(\"--workspace\".into());\n                args.push(filter.clone());\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_list_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions::default());\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { recursive: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--recursive\"]);\n    }\n\n    #[test]\n    fn test_npm_list_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions::default());\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_npm_list_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { recursive: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--workspaces\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions::default());\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_recursive_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { recursive: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_yarn2_list_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions::default());\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_pnpm_list_global_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { global: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"-g\"]);\n    }\n\n    #[test]\n    fn test_npm_list_global() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { global: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"-g\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_global_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { global: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"-g\"]);\n    }\n\n    #[test]\n    fn test_global_list_with_depth() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            global: true,\n            depth: Some(0),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--depth\", \"0\", \"-g\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_with_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string(), \"web\".to_string()];\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"--filter\", \"web\", \"list\"]);\n    }\n\n    #[test]\n    fn test_npm_list_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--workspace\", \"app\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_with_filter_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_prod() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { prod: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--prod\"]);\n    }\n\n    #[test]\n    fn test_npm_list_prod() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { prod: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--include\", \"prod\", \"--include\", \"peer\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_prod_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { prod: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_dev() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { dev: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--dev\"]);\n    }\n\n    #[test]\n    fn test_npm_list_dev() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { dev: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--include\", \"dev\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_dev_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result =\n            pm.resolve_list_command(&ListCommandOptions { dev: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_no_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm\n            .resolve_list_command(&ListCommandOptions { no_optional: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--no-optional\"]);\n    }\n\n    #[test]\n    fn test_npm_list_no_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm\n            .resolve_list_command(&ListCommandOptions { no_optional: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--omit\", \"optional\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_no_optional_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm\n            .resolve_list_command(&ListCommandOptions { no_optional: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_only_projects() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            only_projects: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--only-projects\"]);\n    }\n\n    #[test]\n    fn test_npm_list_only_projects_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            only_projects: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_only_projects_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            only_projects: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_exclude_peers() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            exclude_peers: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--exclude-peers\"]);\n    }\n\n    #[test]\n    fn test_npm_list_exclude_peers() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            exclude_peers: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\", \"--omit\", \"peer\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_exclude_peers_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            exclude_peers: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_pnpm_list_find_by() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            find_by: Some(\"customFinder\"),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"list\", \"--find-by\", \"customFinder\"]);\n    }\n\n    #[test]\n    fn test_npm_list_find_by_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            find_by: Some(\"customFinder\"),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n\n    #[test]\n    fn test_yarn1_list_find_by_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_list_command(&ListCommandOptions {\n            find_by: Some(\"customFinder\"),\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"list\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/login.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the login command.\n#[derive(Debug)]\npub struct LoginCommandOptions<'a> {\n    pub registry: Option<&'a str>,\n    pub scope: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the login command with the package manager.\n    #[must_use]\n    pub async fn run_login_command(\n        &self,\n        options: &LoginCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_login_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the login command.\n    /// All package managers support login.\n    #[must_use]\n    pub fn resolve_login_command(&self, options: &LoginCommandOptions) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Pnpm | PackageManagerType::Npm => {\n                // pnpm delegates login to npm\n                bin_name = \"npm\".into();\n                args.push(\"login\".into());\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                if is_yarn1 {\n                    args.push(\"login\".into());\n                } else {\n                    args.push(\"npm\".into());\n                    args.push(\"login\".into());\n                }\n            }\n        }\n\n        if let Some(registry) = options.registry {\n            args.push(\"--registry\".into());\n            args.push(registry.to_string());\n        }\n\n        if let Some(scope) = options.scope {\n            args.push(\"--scope\".into());\n            args.push(scope.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let _temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_npm_login() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_login_command(&LoginCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"login\"]);\n    }\n\n    #[test]\n    fn test_pnpm_login_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_login_command(&LoginCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"login\"]);\n    }\n\n    #[test]\n    fn test_yarn1_login() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_login_command(&LoginCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"login\"]);\n    }\n\n    #[test]\n    fn test_yarn2_login() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_login_command(&LoginCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"npm\", \"login\"]);\n    }\n\n    #[test]\n    fn test_login_with_registry() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_login_command(&LoginCommandOptions {\n            registry: Some(\"https://registry.example.com\"),\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"login\", \"--registry\", \"https://registry.example.com\"]);\n    }\n\n    #[test]\n    fn test_login_with_scope() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_login_command(&LoginCommandOptions {\n            registry: None,\n            scope: Some(\"@myorg\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"login\", \"--scope\", \"@myorg\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/logout.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the logout command.\n#[derive(Debug)]\npub struct LogoutCommandOptions<'a> {\n    pub registry: Option<&'a str>,\n    pub scope: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the logout command with the package manager.\n    #[must_use]\n    pub async fn run_logout_command(\n        &self,\n        options: &LogoutCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_logout_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the logout command.\n    /// All package managers support logout.\n    #[must_use]\n    pub fn resolve_logout_command(&self, options: &LogoutCommandOptions) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Pnpm | PackageManagerType::Npm => {\n                // pnpm delegates logout to npm\n                bin_name = \"npm\".into();\n                args.push(\"logout\".into());\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                if is_yarn1 {\n                    args.push(\"logout\".into());\n                } else {\n                    args.push(\"npm\".into());\n                    args.push(\"logout\".into());\n                }\n            }\n        }\n\n        if let Some(registry) = options.registry {\n            args.push(\"--registry\".into());\n            args.push(registry.to_string());\n        }\n\n        if let Some(scope) = options.scope {\n            args.push(\"--scope\".into());\n            args.push(scope.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let _temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_npm_logout() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_logout_command(&LogoutCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"logout\"]);\n    }\n\n    #[test]\n    fn test_pnpm_logout_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_logout_command(&LogoutCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"logout\"]);\n    }\n\n    #[test]\n    fn test_yarn1_logout() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_logout_command(&LogoutCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"logout\"]);\n    }\n\n    #[test]\n    fn test_yarn2_logout() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_logout_command(&LogoutCommandOptions {\n            registry: None,\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"npm\", \"logout\"]);\n    }\n\n    #[test]\n    fn test_logout_with_registry() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_logout_command(&LogoutCommandOptions {\n            registry: Some(\"https://registry.example.com\"),\n            scope: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"logout\", \"--registry\", \"https://registry.example.com\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/mod.rs",
    "content": "pub mod add;\npub mod audit;\npub mod cache;\npub mod config;\npub mod dedupe;\npub mod deprecate;\npub mod dist_tag;\npub mod dlx;\npub mod fund;\npub mod install;\npub mod link;\npub mod list;\npub mod login;\npub mod logout;\npub mod outdated;\npub mod owner;\npub mod pack;\npub mod ping;\npub mod prune;\npub mod publish;\npub mod rebuild;\npub mod remove;\npub mod run;\npub mod search;\npub mod token;\npub mod unlink;\npub mod update;\npub mod view;\npub mod whoami;\npub mod why;\n"
  },
  {
    "path": "crates/vite_install/src/commands/outdated.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus, str::FromStr};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Output format for the outdated command.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Format {\n    /// Table format (default)\n    Table,\n    /// List format (parseable)\n    List,\n    /// JSON format\n    Json,\n}\n\nimpl Format {\n    /// Convert format to string representation\n    pub const fn as_str(self) -> &'static str {\n        match self {\n            Format::Table => \"table\",\n            Format::List => \"list\",\n            Format::Json => \"json\",\n        }\n    }\n}\n\nimpl FromStr for Format {\n    type Err = String;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        match s.to_lowercase().as_str() {\n            \"table\" => Ok(Format::Table),\n            \"list\" => Ok(Format::List),\n            \"json\" => Ok(Format::Json),\n            _ => Err(format!(\"Invalid format '{}'. Valid formats: table, list, json\", s)),\n        }\n    }\n}\n\nimpl std::fmt::Display for Format {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{}\", self.as_str())\n    }\n}\n\n/// Options for the outdated command.\n#[derive(Debug, Default)]\npub struct OutdatedCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub long: bool,\n    pub format: Option<Format>,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub prod: bool,\n    pub dev: bool,\n    pub no_optional: bool,\n    pub compatible: bool,\n    pub sort_by: Option<&'a str>,\n    pub global: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the outdated command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_outdated_command(\n        &self,\n        options: &OutdatedCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_outdated_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the outdated command.\n    #[must_use]\n    pub fn resolve_outdated_command(\n        &self,\n        options: &OutdatedCommandOptions,\n    ) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        // Global packages should use npm cli only\n        if options.global {\n            bin_name = \"npm\".into();\n            Self::format_npm_outdated_args(&mut args, options);\n            args.push(\"-g\".into());\n        } else {\n            match self.client {\n                PackageManagerType::Pnpm => {\n                    bin_name = \"pnpm\".into();\n\n                    // pnpm: --filter must come before command\n                    if let Some(filters) = options.filters {\n                        for filter in filters {\n                            args.push(\"--filter\".into());\n                            args.push(filter.clone());\n                        }\n                    }\n\n                    args.push(\"outdated\".into());\n\n                    // Handle format option\n                    if let Some(format) = options.format {\n                        args.push(\"--format\".into());\n                        args.push(format.as_str().into());\n                    }\n\n                    if options.long {\n                        args.push(\"--long\".into());\n                    }\n\n                    if options.workspace_root {\n                        args.push(\"--workspace-root\".into());\n                    }\n\n                    if options.recursive {\n                        args.push(\"--recursive\".into());\n                    }\n\n                    if options.prod {\n                        args.push(\"--prod\".into());\n                    }\n\n                    if options.dev {\n                        args.push(\"--dev\".into());\n                    }\n\n                    if options.no_optional {\n                        args.push(\"--no-optional\".into());\n                    }\n\n                    if options.compatible {\n                        args.push(\"--compatible\".into());\n                    }\n\n                    if let Some(sort_by) = options.sort_by {\n                        args.push(\"--sort-by\".into());\n                        args.push(sort_by.into());\n                    }\n\n                    // Add packages (pnpm supports glob patterns)\n                    args.extend_from_slice(options.packages);\n                }\n                PackageManagerType::Yarn => {\n                    bin_name = \"yarn\".into();\n\n                    // Check if yarn@2+ (uses upgrade-interactive)\n                    if !self.version.starts_with(\"1.\") {\n                        output::note(\n                            \"yarn@2+ uses 'yarn upgrade-interactive' for checking outdated packages\",\n                        );\n                        args.push(\"upgrade-interactive\".into());\n\n                        // Warn about unsupported flags\n                        if options.format.is_some() {\n                            output::warn(\"--format not supported by yarn@2+\");\n                        }\n                    } else {\n                        // yarn@1\n                        args.push(\"outdated\".into());\n\n                        // Add packages (yarn@1 supports package names)\n                        args.extend_from_slice(options.packages);\n\n                        // yarn@1 supports --json format\n                        if let Some(format) = options.format {\n                            match format {\n                                Format::Json => args.push(\"--json\".into()),\n                                Format::List => {\n                                    output::warn(\"yarn@1 not support list format\");\n                                }\n                                Format::Table => {} // Default, no flag needed\n                            }\n                        }\n                    }\n\n                    // Common warnings\n                    if options.long {\n                        output::warn(\"--long not supported by yarn\");\n                    }\n                    if options.workspace_root {\n                        output::warn(\"--workspace-root not supported by yarn\");\n                    }\n                    if options.recursive {\n                        output::warn(\"--recursive not supported by yarn\");\n                    }\n                    if let Some(filters) = options.filters {\n                        if !filters.is_empty() {\n                            output::warn(\"--filter not supported by yarn\");\n                        }\n                    }\n                    if options.prod || options.dev {\n                        output::warn(\"--prod/--dev not supported by yarn\");\n                    }\n                    if options.no_optional {\n                        output::warn(\"--no-optional not supported by yarn\");\n                    }\n                    if options.compatible {\n                        output::warn(\"--compatible not supported by yarn\");\n                    }\n                    if options.sort_by.is_some() {\n                        output::warn(\"--sort-by not supported by yarn\");\n                    }\n                }\n                PackageManagerType::Npm => {\n                    bin_name = \"npm\".into();\n                    Self::format_npm_outdated_args(&mut args, options);\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n\n    fn format_npm_outdated_args(args: &mut Vec<String>, options: &OutdatedCommandOptions) {\n        args.push(\"outdated\".into());\n\n        // npm format flags - translate from --format\n        if let Some(format) = options.format {\n            match format {\n                Format::Json => args.push(\"--json\".into()),\n                Format::List => args.push(\"--parseable\".into()),\n                Format::Table => {} // Default, no flag needed\n            }\n        }\n\n        if options.long {\n            args.push(\"--long\".into());\n        }\n\n        // npm workspace flags - translate from --filter\n        if let Some(filters) = options.filters {\n            for filter in filters {\n                args.push(\"--workspace\".into());\n                args.push(filter.clone());\n            }\n        }\n\n        // npm uses --include-workspace-root when workspace_root is set\n        if options.workspace_root {\n            args.push(\"--include-workspace-root\".into());\n        }\n\n        // npm --all translates from -r/--recursive\n        if options.recursive {\n            args.push(\"--all\".into());\n        }\n\n        // Add packages (npm supports package names)\n        args.extend_from_slice(options.packages);\n\n        // Warn about pnpm-specific flags\n        if options.prod || options.dev {\n            output::warn(\"--prod/--dev not supported by npm\");\n        }\n        if options.no_optional {\n            output::warn(\"--no-optional not supported by npm\");\n        }\n        if options.compatible {\n            output::warn(\"--compatible not supported by npm\");\n        }\n        if options.sort_by.is_some() {\n            output::warn(\"--sort-by not supported by npm\");\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_outdated_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"outdated\"]);\n    }\n\n    #[test]\n    fn test_pnpm_outdated_with_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"*babel*\".to_string(), \"eslint-*\".to_string()];\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            packages: &packages,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"outdated\", \"*babel*\", \"eslint-*\"]);\n    }\n\n    #[test]\n    fn test_pnpm_outdated_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            format: Some(Format::Json),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"outdated\", \"--format\", \"json\"]);\n    }\n\n    #[test]\n    fn test_npm_outdated_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions { ..Default::default() });\n        assert_eq!(result.args, vec![\"outdated\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_outdated_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            format: Some(Format::Json),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"outdated\", \"--json\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_yarn_outdated_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.19\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions { ..Default::default() });\n        assert_eq!(result.args, vec![\"outdated\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_pnpm_outdated_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            filters: Some(&filters),\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"outdated\", \"--recursive\"]);\n    }\n\n    #[test]\n    fn test_pnpm_outdated_prod_only() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm\n            .resolve_outdated_command(&OutdatedCommandOptions { prod: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"outdated\", \"--prod\"]);\n    }\n\n    #[test]\n    fn test_npm_outdated_list_format() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            format: Some(Format::List),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"outdated\", \"--parseable\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_outdated_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"outdated\", \"--all\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_outdated_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"outdated\", \"--workspace\", \"app\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_global_outdated() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            global: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"outdated\", \"-g\"]);\n    }\n\n    #[test]\n    fn test_pnpm_outdated_with_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            workspace_root: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"outdated\", \"--workspace-root\"]);\n    }\n\n    #[test]\n    fn test_pnpm_outdated_with_workspace_root_and_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            workspace_root: true,\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"outdated\", \"--workspace-root\", \"--recursive\"]);\n    }\n\n    #[test]\n    fn test_pnpm_outdated_with_all_flags() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let packages = vec![\"react\".to_string()];\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            packages: &packages,\n            long: true,\n            format: Some(Format::Json),\n            recursive: true,\n            filters: Some(&filters),\n            workspace_root: true,\n            prod: true,\n            compatible: true,\n            sort_by: Some(\"name\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(\n            result.args,\n            vec![\n                \"--filter\",\n                \"app\",\n                \"outdated\",\n                \"--format\",\n                \"json\",\n                \"--long\",\n                \"--workspace-root\",\n                \"--recursive\",\n                \"--prod\",\n                \"--compatible\",\n                \"--sort-by\",\n                \"name\",\n                \"react\"\n            ]\n        );\n    }\n\n    #[test]\n    fn test_npm_outdated_with_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            workspace_root: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"outdated\", \"--include-workspace-root\"]);\n    }\n\n    #[test]\n    fn test_npm_outdated_with_workspace_root_and_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_outdated_command(&OutdatedCommandOptions {\n            filters: Some(&filters),\n            workspace_root: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"outdated\", \"--workspace\", \"app\", \"--include-workspace-root\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/owner.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Owner subcommand type.\n#[derive(Debug, Clone)]\npub enum OwnerSubcommand {\n    List { package: String, otp: Option<String> },\n    Add { user: String, package: String, otp: Option<String> },\n    Rm { user: String, package: String, otp: Option<String> },\n}\n\nimpl PackageManager {\n    /// Run the owner command with the package manager.\n    #[must_use]\n    pub async fn run_owner_command(\n        &self,\n        subcommand: &OwnerSubcommand,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_owner_command(subcommand);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the owner command.\n    /// All package managers delegate to npm owner.\n    #[must_use]\n    pub fn resolve_owner_command(&self, subcommand: &OwnerSubcommand) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"owner\".into());\n\n        match subcommand {\n            OwnerSubcommand::List { package, otp } => {\n                args.push(\"list\".into());\n                args.push(package.clone());\n\n                if let Some(otp_value) = otp {\n                    args.push(\"--otp\".into());\n                    args.push(otp_value.clone());\n                }\n            }\n            OwnerSubcommand::Add { user, package, otp } => {\n                args.push(\"add\".into());\n                args.push(user.clone());\n                args.push(package.clone());\n\n                if let Some(otp_value) = otp {\n                    args.push(\"--otp\".into());\n                    args.push(otp_value.clone());\n                }\n            }\n            OwnerSubcommand::Rm { user, package, otp } => {\n                args.push(\"rm\".into());\n                args.push(user.clone());\n                args.push(package.clone());\n\n                if let Some(otp_value) = otp {\n                    args.push(\"--otp\".into());\n                    args.push(otp_value.clone());\n                }\n            }\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_owner_list_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_owner_command(&OwnerSubcommand::List {\n            package: \"my-package\".to_string(),\n            otp: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"owner\", \"list\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_npm_owner_add() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_owner_command(&OwnerSubcommand::Add {\n            user: \"username\".to_string(),\n            package: \"my-package\".to_string(),\n            otp: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"owner\", \"add\", \"username\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_yarn_owner_rm_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_owner_command(&OwnerSubcommand::Rm {\n            user: \"username\".to_string(),\n            package: \"my-package\".to_string(),\n            otp: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"owner\", \"rm\", \"username\", \"my-package\"]);\n    }\n\n    #[test]\n    fn test_owner_with_otp() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_owner_command(&OwnerSubcommand::Add {\n            user: \"username\".to_string(),\n            package: \"my-package\".to_string(),\n            otp: Some(\"123456\".to_string()),\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"owner\", \"add\", \"username\", \"my-package\", \"--otp\", \"123456\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/pack.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse tokio::fs::create_dir_all;\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the pack command.\n#[derive(Debug, Default)]\npub struct PackCommandOptions<'a> {\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub out: Option<&'a str>,\n    pub pack_destination: Option<&'a str>,\n    pub pack_gzip_level: Option<u8>,\n    pub json: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the pack command with the package manager.\n    #[must_use]\n    pub async fn run_pack_command(\n        &self,\n        options: &PackCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        // Special handling for npm: create pack-destination directory if it doesn't exist\n        if matches!(self.client, PackageManagerType::Npm) {\n            if let Some(pack_destination) = options.pack_destination {\n                let dest_path = cwd.as_ref().join(pack_destination);\n                if !dest_path.as_path().exists() {\n                    create_dir_all(&dest_path)\n                        .await\n                        .map_err(|e| Error::IoWithPath { path: dest_path.into(), err: e })?;\n                }\n            }\n        }\n\n        let resolve_command = self.resolve_pack_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the pack command.\n    #[must_use]\n    pub fn resolve_pack_command(&self, options: &PackCommandOptions) -> ResolveCommandResult {\n        let bin_name: String = self.client.to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                args.push(\"pack\".into());\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n\n                if let Some(out) = options.out {\n                    args.push(\"--out\".into());\n                    args.push(out.to_string());\n                }\n\n                if let Some(dest) = options.pack_destination {\n                    args.push(\"--pack-destination\".into());\n                    args.push(dest.to_string());\n                }\n\n                if let Some(level) = options.pack_gzip_level {\n                    args.push(\"--pack-gzip-level\".into());\n                    args.push(level.to_string());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                args.push(\"pack\".into());\n\n                if options.recursive {\n                    args.push(\"--workspaces\".into());\n                }\n\n                // npm: --workspace comes after command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                if options.out.is_some() {\n                    output::warn(\"--out not supported by npm\");\n                }\n\n                if let Some(dest) = options.pack_destination {\n                    args.push(\"--pack-destination\".into());\n                    args.push(dest.to_string());\n                }\n\n                if options.pack_gzip_level.is_some() {\n                    output::warn(\"--pack-gzip-level not supported by npm\");\n                }\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                let is_yarn1 = self.version.starts_with(\"1.\");\n                let has_filters = options.filters.is_some_and(|f| !f.is_empty());\n\n                // yarn@2+ uses 'workspaces foreach' for recursive or filters\n                if !is_yarn1 && (options.recursive || has_filters) {\n                    args.push(\"workspaces\".into());\n                    args.push(\"foreach\".into());\n                    args.push(\"--all\".into());\n\n                    // Add --include for each filter\n                    if let Some(filters) = options.filters {\n                        for filter in filters {\n                            args.push(\"--include\".into());\n                            args.push(filter.clone());\n                        }\n                    }\n\n                    args.push(\"pack\".into());\n                } else {\n                    // yarn@1 or single package pack\n                    if options.recursive && is_yarn1 {\n                        output::warn(\n                            \"yarn@1 does not support recursive pack, ignoring --recursive flag\",\n                        );\n                    }\n                    if has_filters && is_yarn1 {\n                        output::warn(\"yarn@1 does not support --filter, ignoring --filter flag\");\n                    }\n                    args.push(\"pack\".into());\n                }\n\n                if let Some(out) = options.out {\n                    if is_yarn1 {\n                        args.push(\"--filename\".into());\n                    } else {\n                        args.push(\"--out\".into());\n                    }\n                    args.push(out.to_string());\n                }\n\n                if options.pack_destination.is_some() {\n                    output::warn(\"--pack-destination not supported by yarn\");\n                }\n\n                if options.pack_gzip_level.is_some() {\n                    output::warn(\"--pack-gzip-level not supported by yarn\");\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_pack_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions::default());\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"pack\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"pack\", \"--recursive\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_with_out() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            out: Some(\"./dist/package.tgz\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"pack\", \"--out\", \"./dist/package.tgz\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_with_destination() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            pack_destination: Some(\"./dist\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"pack\", \"--pack-destination\", \"./dist\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_with_gzip_level() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            pack_gzip_level: Some(9),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"pack\", \"--pack-gzip-level\", \"9\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"pack\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"pack\"]);\n    }\n\n    #[test]\n    fn test_pnpm_pack_with_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string(), \"web\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"--filter\", \"web\", \"pack\"]);\n    }\n\n    #[test]\n    fn test_npm_pack_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions::default());\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"pack\"]);\n    }\n\n    #[test]\n    fn test_npm_pack_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"pack\", \"--workspaces\"]);\n    }\n\n    #[test]\n    fn test_npm_pack_with_destination() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            pack_destination: Some(\"./dist\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"pack\", \"--pack-destination\", \"./dist\"]);\n    }\n\n    #[test]\n    fn test_npm_pack_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"pack\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_npm_pack_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"pack\", \"--workspace\", \"app\"]);\n    }\n\n    #[test]\n    fn test_npm_pack_with_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string(), \"web\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"pack\", \"--workspace\", \"app\", \"--workspace\", \"web\"]);\n    }\n\n    #[test]\n    fn test_yarn1_pack_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions::default());\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\"]);\n    }\n\n    #[test]\n    fn test_yarn1_pack_recursive_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\"]);\n    }\n\n    #[test]\n    fn test_yarn1_pack_with_out() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            out: Some(\"./dist/package.tgz\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\", \"--filename\", \"./dist/package.tgz\"]);\n    }\n\n    #[test]\n    fn test_yarn1_pack_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_yarn1_pack_with_filter_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\"]);\n    }\n\n    #[test]\n    fn test_yarn2_pack_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions::default());\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\"]);\n    }\n\n    #[test]\n    fn test_yarn2_pack_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { recursive: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"workspaces\", \"foreach\", \"--all\", \"pack\"]);\n    }\n\n    #[test]\n    fn test_yarn2_pack_with_out() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            out: Some(\"./dist/package.tgz\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\", \"--out\", \"./dist/package.tgz\"]);\n    }\n\n    #[test]\n    fn test_yarn2_pack_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result =\n            pm.resolve_pack_command(&PackCommandOptions { json: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"pack\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_yarn2_pack_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"workspaces\", \"foreach\", \"--all\", \"--include\", \"app\", \"pack\"]);\n    }\n\n    #[test]\n    fn test_yarn2_pack_with_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let filters = vec![\"app\".to_string(), \"web\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(\n            result.args,\n            vec![\"workspaces\", \"foreach\", \"--all\", \"--include\", \"app\", \"--include\", \"web\", \"pack\"]\n        );\n    }\n\n    #[test]\n    fn test_yarn2_pack_with_filter_and_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_pack_command(&PackCommandOptions {\n            recursive: true,\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        // Filter takes precedence, same command structure\n        assert_eq!(result.args, vec![\"workspaces\", \"foreach\", \"--all\", \"--include\", \"app\", \"pack\"]);\n    }\n\n    #[tokio::test]\n    async fn test_npm_pack_destination_creates_directory() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        let pm = PackageManager {\n            client: PackageManagerType::Npm,\n            package_name: \"npm\".into(),\n            version: Str::from(\"10.0.0\"),\n            hash: None,\n            bin_name: \"npm\".into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        };\n\n        let dest_dir = \"test-dest\";\n        let dest_path = temp_dir_path.join(dest_dir);\n\n        // Ensure directory doesn't exist initially\n        assert!(!dest_path.as_path().exists());\n\n        // This would normally run npm pack but we're just testing directory creation\n        // The actual command will fail but directory should be created\n        let options = PackCommandOptions { pack_destination: Some(dest_dir), ..Default::default() };\n\n        // The command will fail because npm isn't actually available, but directory should be created\n        let _ = pm.run_pack_command(&options, &temp_dir_path).await;\n\n        // Verify directory was created\n        assert!(dest_path.as_path().exists());\n        assert!(dest_path.as_path().is_dir());\n    }\n\n    #[tokio::test]\n    async fn test_pnpm_pack_destination_no_directory_creation() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        let pm = PackageManager {\n            client: PackageManagerType::Pnpm,\n            package_name: \"pnpm\".into(),\n            version: Str::from(\"10.0.0\"),\n            hash: None,\n            bin_name: \"pnpm\".into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        };\n\n        let dest_dir = \"test-dest\";\n        let dest_path = temp_dir_path.join(dest_dir);\n\n        // Ensure directory doesn't exist initially\n        assert!(!dest_path.as_path().exists());\n\n        let options = PackCommandOptions { pack_destination: Some(dest_dir), ..Default::default() };\n\n        // The command will fail because pnpm isn't actually available, but directory should NOT be created\n        let _ = pm.run_pack_command(&options, &temp_dir_path).await;\n\n        // Verify directory was NOT created (pnpm handles this itself)\n        assert!(!dest_path.as_path().exists());\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/ping.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Options for the ping command.\n#[derive(Debug, Default)]\npub struct PingCommandOptions<'a> {\n    pub registry: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the ping command with the package manager.\n    #[must_use]\n    pub async fn run_ping_command(\n        &self,\n        options: &PingCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_ping_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the ping command.\n    /// All package managers delegate to npm ping.\n    #[must_use]\n    pub fn resolve_ping_command(&self, options: &PingCommandOptions) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"ping\".into());\n\n        if let Some(registry_value) = options.registry {\n            args.push(\"--registry\".into());\n            args.push(registry_value.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_ping_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm\n            .resolve_ping_command(&PingCommandOptions { registry: None, pass_through_args: None });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"ping\"]);\n    }\n\n    #[test]\n    fn test_ping_with_registry() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_ping_command(&PingCommandOptions {\n            registry: Some(\"https://registry.npmjs.org\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"ping\", \"--registry\", \"https://registry.npmjs.org\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/prune.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the prune command.\n#[derive(Debug, Default)]\npub struct PruneCommandOptions<'a> {\n    pub prod: bool,\n    pub no_optional: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the prune command with the package manager.\n    /// Returns ExitStatus with success (0) if the command is not supported.\n    #[must_use]\n    pub async fn run_prune_command(\n        &self,\n        options: &PruneCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let Some(resolve_command) = self.resolve_prune_command(options) else {\n            // Command not supported, return success\n            return Ok(ExitStatus::default());\n        };\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the prune command.\n    /// Returns None if the command is not supported by the package manager.\n    #[must_use]\n    pub fn resolve_prune_command(\n        &self,\n        options: &PruneCommandOptions,\n    ) -> Option<ResolveCommandResult> {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"prune\".into());\n\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"prune\".into());\n\n                // npm uses --omit flags instead of --prod and --no-optional\n                if options.prod {\n                    args.push(\"--omit=dev\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--omit=optional\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                output::warn(\n                    \"yarn does not have 'prune' command. yarn install will prune extraneous packages automatically.\",\n                );\n                return None;\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        Some(ResolveCommandResult { bin_path: bin_name, args, envs })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_prune() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_prune_command(&PruneCommandOptions::default());\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"prune\"]);\n    }\n\n    #[test]\n    fn test_pnpm_prune_prod() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_prune_command(&PruneCommandOptions { prod: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"prune\", \"--prod\"]);\n    }\n\n    #[test]\n    fn test_npm_prune() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_prune_command(&PruneCommandOptions::default());\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"prune\"]);\n    }\n\n    #[test]\n    fn test_npm_prune_prod() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_prune_command(&PruneCommandOptions { prod: true, ..Default::default() });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"prune\", \"--omit=dev\"]);\n    }\n\n    #[test]\n    fn test_npm_prune_no_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_prune_command(&PruneCommandOptions {\n            no_optional: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"prune\", \"--omit=optional\"]);\n    }\n\n    #[test]\n    fn test_npm_prune_both_flags() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_prune_command(&PruneCommandOptions {\n            prod: true,\n            no_optional: true,\n            ..Default::default()\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"prune\", \"--omit=dev\", \"--omit=optional\"]);\n    }\n\n    #[test]\n    fn test_yarn1_prune_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_prune_command(&PruneCommandOptions::default());\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_yarn2_prune_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_prune_command(&PruneCommandOptions::default());\n        assert!(result.is_none());\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/publish.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the publish command.\n#[derive(Debug, Default)]\npub struct PublishCommandOptions<'a> {\n    pub target: Option<&'a str>,\n    pub dry_run: bool,\n    pub tag: Option<&'a str>,\n    pub access: Option<&'a str>,\n    pub otp: Option<&'a str>,\n    pub no_git_checks: bool,\n    pub publish_branch: Option<&'a str>,\n    pub report_summary: bool,\n    pub force: bool,\n    pub json: bool,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the publish command with the package manager.\n    #[must_use]\n    pub async fn run_publish_command(\n        &self,\n        options: &PublishCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_publish_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the publish command.\n    /// All yarn versions delegate to npm publish.\n    #[must_use]\n    pub fn resolve_publish_command(&self, options: &PublishCommandOptions) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                args.push(\"publish\".into());\n\n                if let Some(target) = options.target {\n                    args.push(target.to_string());\n                }\n\n                if options.dry_run {\n                    args.push(\"--dry-run\".into());\n                }\n\n                if let Some(tag) = options.tag {\n                    args.push(\"--tag\".into());\n                    args.push(tag.to_string());\n                }\n\n                if let Some(access) = options.access {\n                    args.push(\"--access\".into());\n                    args.push(access.to_string());\n                }\n\n                if let Some(otp) = options.otp {\n                    args.push(\"--otp\".into());\n                    args.push(otp.to_string());\n                }\n\n                if options.no_git_checks {\n                    args.push(\"--no-git-checks\".into());\n                }\n\n                if let Some(branch) = options.publish_branch {\n                    args.push(\"--publish-branch\".into());\n                    args.push(branch.to_string());\n                }\n\n                if options.report_summary {\n                    args.push(\"--report-summary\".into());\n                }\n\n                if options.force {\n                    args.push(\"--force\".into());\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n            }\n            PackageManagerType::Npm | PackageManagerType::Yarn => {\n                // Yarn always delegates to npm\n                bin_name = \"npm\".into();\n\n                args.push(\"publish\".into());\n\n                if options.recursive {\n                    args.push(\"--workspaces\".into());\n                }\n\n                // npm: --workspace comes after command (maps from --filter)\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                if let Some(target) = options.target {\n                    args.push(target.to_string());\n                }\n\n                if options.dry_run {\n                    args.push(\"--dry-run\".into());\n                }\n\n                if let Some(tag) = options.tag {\n                    args.push(\"--tag\".into());\n                    args.push(tag.to_string());\n                }\n\n                if let Some(access) = options.access {\n                    args.push(\"--access\".into());\n                    args.push(access.to_string());\n                }\n\n                if let Some(otp) = options.otp {\n                    args.push(\"--otp\".into());\n                    args.push(otp.to_string());\n                }\n\n                if options.force {\n                    args.push(\"--force\".into());\n                }\n\n                if options.publish_branch.is_some() {\n                    output::warn(\"--publish-branch not supported by npm, ignoring flag\");\n                }\n\n                if options.report_summary {\n                    output::warn(\"--report-summary not supported by npm, ignoring flag\");\n                }\n\n                if options.json {\n                    output::warn(\"--json not supported by npm, ignoring flag\");\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_publish() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions::default());\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_npm_publish() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions::default());\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_yarn1_publish_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions::default());\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_yarn2_publish_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions::default());\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_yarn_publish_with_tag() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            tag: Some(\"beta\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\", \"--tag\", \"beta\"]);\n    }\n\n    #[test]\n    fn test_pnpm_publish_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"publish\", \"--recursive\"]);\n    }\n\n    #[test]\n    fn test_npm_publish_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\", \"--workspaces\"]);\n    }\n\n    #[test]\n    fn test_pnpm_publish_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"publish\"]);\n    }\n\n    #[test]\n    fn test_npm_publish_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\", \"--workspace\", \"app\"]);\n    }\n\n    #[test]\n    fn test_yarn_publish_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\", \"--workspace\", \"app\"]);\n    }\n\n    #[test]\n    fn test_pnpm_publish_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result =\n            pm.resolve_publish_command(&PublishCommandOptions { json: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"publish\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_npm_publish_json_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result =\n            pm.resolve_publish_command(&PublishCommandOptions { json: true, ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_pnpm_publish_branch() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            publish_branch: Some(\"main\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"publish\", \"--publish-branch\", \"main\"]);\n    }\n\n    #[test]\n    fn test_npm_publish_branch_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            publish_branch: Some(\"main\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_pnpm_publish_report_summary() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            report_summary: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"publish\", \"--report-summary\"]);\n    }\n\n    #[test]\n    fn test_npm_publish_report_summary_ignored() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            report_summary: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\"]);\n    }\n\n    #[test]\n    fn test_pnpm_publish_otp() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            otp: Some(\"123456\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"publish\", \"--otp\", \"123456\"]);\n    }\n\n    #[test]\n    fn test_npm_publish_otp() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            otp: Some(\"654321\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\", \"--otp\", \"654321\"]);\n    }\n\n    #[test]\n    fn test_yarn_publish_otp() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_publish_command(&PublishCommandOptions {\n            otp: Some(\"999999\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"publish\", \"--otp\", \"999999\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/rebuild.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the rebuild command.\n#[derive(Debug)]\npub struct RebuildCommandOptions<'a> {\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the rebuild command with the package manager.\n    /// Returns ExitStatus with success (0) if the command is not supported.\n    #[must_use]\n    pub async fn run_rebuild_command(\n        &self,\n        options: &RebuildCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let Some(resolve_command) = self.resolve_rebuild_command(options) else {\n            return Ok(ExitStatus::default());\n        };\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the rebuild command.\n    /// Returns None if the command is not supported by the package manager.\n    #[must_use]\n    pub fn resolve_rebuild_command(\n        &self,\n        options: &RebuildCommandOptions,\n    ) -> Option<ResolveCommandResult> {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"rebuild\".into());\n            }\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"rebuild\".into());\n            }\n            PackageManagerType::Yarn => {\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                if is_yarn1 {\n                    output::warn(\"yarn v1 does not support the rebuild command\");\n                } else {\n                    output::warn(\"yarn berry does not support the rebuild command\");\n                }\n\n                return None;\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        Some(ResolveCommandResult { bin_path: bin_name, args, envs })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let _temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_npm_rebuild() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_rebuild_command(&RebuildCommandOptions { pass_through_args: None });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"rebuild\"]);\n    }\n\n    #[test]\n    fn test_pnpm_rebuild() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_rebuild_command(&RebuildCommandOptions { pass_through_args: None });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"rebuild\"]);\n    }\n\n    #[test]\n    fn test_yarn1_rebuild_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_rebuild_command(&RebuildCommandOptions { pass_through_args: None });\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_yarn2_rebuild_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_rebuild_command(&RebuildCommandOptions { pass_through_args: None });\n        assert!(result.is_none());\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/remove.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the remove command.\n#[derive(Debug, Default)]\npub struct RemoveCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub recursive: bool,\n    pub global: bool,\n    pub save_dev: bool,\n    pub save_optional: bool,\n    pub save_prod: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the remove command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_remove_command(\n        &self,\n        options: &RemoveCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_remove_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the remove command.\n    #[must_use]\n    pub fn resolve_remove_command(&self, options: &RemoveCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        // global packages should use npm cli only\n        if options.global {\n            // TODO(@fengmk2): Need to handle the case where the npm CLI does not exist in the PATH\n            bin_name = \"npm\".into();\n            args.push(\"uninstall\".into());\n            args.push(\"--global\".into());\n            if let Some(pass_through_args) = options.pass_through_args {\n                args.extend_from_slice(pass_through_args);\n            }\n            args.extend_from_slice(options.packages);\n\n            return ResolveCommandResult { bin_path: bin_name, args, envs };\n        }\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"remove\".into());\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n                // https://pnpm.io/cli/remove#options\n                if options.save_dev {\n                    args.push(\"--save-dev\".into());\n                }\n                if options.save_optional {\n                    args.push(\"--save-optional\".into());\n                }\n                if options.save_prod {\n                    args.push(\"--save-prod\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                // NOTE: filters are not supported in recursive mode\n                // yarn: workspaces foreach --all --include {filter} remove\n                // https://yarnpkg.com/cli/workspace\n                if let Some(filters) = options.filters\n                    && !options.recursive\n                {\n                    args.push(\"workspaces\".into());\n                    args.push(\"foreach\".into());\n                    args.push(\"--all\".into());\n                    for filter in filters {\n                        args.push(\"--include\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"remove\".into());\n                if options.recursive {\n                    args.push(\"--all\".into());\n                }\n                // NOTE: yarn doesn't support -w flag for workspace root in remove command\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                // npm: uninstall --workspace <pkg>\n                args.push(\"uninstall\".into());\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                // https://docs.npmjs.com/cli/v11/commands/npm-uninstall#configuration\n                if options.workspace_root || options.recursive {\n                    // recursive mode will remove from workspace root\n                    args.push(\"--include-workspace-root\".into());\n                }\n                if options.recursive {\n                    args.push(\"--workspaces\".into());\n                }\n                // not support: save_dev, save_optional, save_prod, just ignore them\n            }\n        }\n\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n        args.extend_from_slice(options.packages);\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(\"1.0.0\"),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_basic_remove() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"remove\", \"lodash\"]);\n    }\n\n    #[test]\n    fn test_pnpm_remove_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"remove\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_remove_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            filters: None,\n            workspace_root: true,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--workspace-root\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_remove_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: true,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--recursive\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_remove_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"axios\".to_string()],\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"--filter\", \"web\", \"remove\", \"axios\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_yarn_basic_remove() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_remove_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\"workspaces\", \"foreach\", \"--all\", \"--include\", \"app\", \"remove\", \"lodash\"]\n        );\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_remove_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: true,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--all\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_npm_basic_remove() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_remove_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"--workspace\", \"app\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_remove_workspace_root() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            filters: None,\n            workspace_root: true,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"--include-workspace-root\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_remove_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: true,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\"uninstall\", \"--include-workspace-root\", \"--workspaces\", \"lodash\"]\n        );\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_remove_multiple_workspaces() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\"uninstall\", \"--workspace\", \"app\", \"--workspace\", \"web\", \"lodash\"]\n        );\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_global_remove() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: true,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"--global\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_remove_multiple_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string(), \"axios\".to_string(), \"underscore\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"lodash\", \"axios\", \"underscore\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_remove_with_pass_through_args() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: Some(&[\"--use-stderr\".to_string()]),\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--use-stderr\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_remove_save_dev() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: true,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--save-dev\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_remove_save_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"sharp\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: true,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--save-optional\", \"sharp\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_remove_save_prod() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"react\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: true,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--save-prod\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_npm_remove_save_dev() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: true,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_remove_save_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"sharp\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: true,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"sharp\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_remove_save_prod() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"react\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: true,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"uninstall\", \"react\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_yarn_remove_save_flags_ignored() {\n        // Yarn doesn't support save flags, so they should be ignored\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: true,\n            save_optional: true,\n            save_prod: true,\n            pass_through_args: None,\n        });\n        // Should not include any save flags for yarn\n        assert_eq!(result.args, vec![\"remove\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_remove_with_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: None,\n            workspace_root: false,\n            recursive: true,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.args, vec![\"remove\", \"--all\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_remove_with_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            workspace_root: false,\n            recursive: false,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        assert_eq!(\n            result.args,\n            vec![\n                \"workspaces\",\n                \"foreach\",\n                \"--all\",\n                \"--include\",\n                \"app\",\n                \"--include\",\n                \"web\",\n                \"remove\",\n                \"lodash\"\n            ]\n        );\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_remove_with_recursive_and_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn);\n        let result = pm.resolve_remove_command(&RemoveCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            workspace_root: false,\n            recursive: true,\n            global: false,\n            save_dev: false,\n            save_optional: false,\n            save_prod: false,\n            pass_through_args: None,\n        });\n        // ignore filters in recursive mode\n        assert_eq!(result.args, vec![\"remove\", \"--all\", \"lodash\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/run.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\nimpl PackageManager {\n    /// Run `<pm> run <args>` to execute a package.json script.\n    pub async fn run_script_command(\n        &self,\n        args: &[String],\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_run_script_command(args);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the `<pm> run <args>` command.\n    #[must_use]\n    pub fn resolve_run_script_command(&self, args: &[String]) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut cmd_args: Vec<String> = vec![\"run\".to_string()];\n        cmd_args.extend(args.iter().cloned());\n\n        let bin_path = match self.client {\n            PackageManagerType::Pnpm => \"pnpm\",\n            PackageManagerType::Npm => \"npm\",\n            PackageManagerType::Yarn => \"yarn\",\n        };\n\n        ResolveCommandResult { bin_path: bin_path.to_string(), args: cmd_args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_run_script() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_run_script_command(&[\"dev\".into()]);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"run\", \"dev\"]);\n    }\n\n    #[test]\n    fn test_pnpm_run_script_with_args() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_run_script_command(&[\"dev\".into(), \"--port\".into(), \"3000\".into()]);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"run\", \"dev\", \"--port\", \"3000\"]);\n    }\n\n    #[test]\n    fn test_npm_run_script() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_run_script_command(&[\"dev\".into()]);\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"run\", \"dev\"]);\n    }\n\n    #[test]\n    fn test_npm_run_script_with_args() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_run_script_command(&[\"dev\".into(), \"--port\".into(), \"3000\".into()]);\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"run\", \"dev\", \"--port\", \"3000\"]);\n    }\n\n    #[test]\n    fn test_yarn_run_script() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_run_script_command(&[\"build\".into()]);\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"run\", \"build\"]);\n    }\n\n    #[test]\n    fn test_run_script_no_args() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_run_script_command(&[]);\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"run\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/search.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Options for the search command.\n#[derive(Debug, Default)]\npub struct SearchCommandOptions<'a> {\n    pub terms: &'a [String],\n    pub json: bool,\n    pub long: bool,\n    pub registry: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the search command with the package manager.\n    #[must_use]\n    pub async fn run_search_command(\n        &self,\n        options: &SearchCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_search_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the search command.\n    /// All package managers delegate to npm search.\n    #[must_use]\n    pub fn resolve_search_command(&self, options: &SearchCommandOptions) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"search\".into());\n\n        for term in options.terms {\n            args.push(term.clone());\n        }\n\n        if options.json {\n            args.push(\"--json\".into());\n        }\n\n        if options.long {\n            args.push(\"--long\".into());\n        }\n\n        if let Some(registry_value) = options.registry {\n            args.push(\"--registry\".into());\n            args.push(registry_value.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_search_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let terms = vec![\"react\".to_string()];\n        let result = pm.resolve_search_command(&SearchCommandOptions {\n            terms: &terms,\n            json: false,\n            long: false,\n            registry: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"search\", \"react\"]);\n    }\n\n    #[test]\n    fn test_search_with_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let terms = vec![\"lodash\".to_string()];\n        let result = pm.resolve_search_command(&SearchCommandOptions {\n            terms: &terms,\n            json: true,\n            long: false,\n            registry: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"search\", \"lodash\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_search_multiple_terms() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let terms = vec![\"react\".to_string(), \"hooks\".to_string(), \"state\".to_string()];\n        let result = pm.resolve_search_command(&SearchCommandOptions {\n            terms: &terms,\n            json: false,\n            long: true,\n            registry: Some(\"https://registry.npmjs.org\"),\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\n                \"search\",\n                \"react\",\n                \"hooks\",\n                \"state\",\n                \"--long\",\n                \"--registry\",\n                \"https://registry.npmjs.org\",\n            ]\n        );\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/token.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Token subcommand type.\n#[derive(Debug, Clone)]\npub enum TokenSubcommand {\n    List {\n        json: bool,\n        registry: Option<String>,\n        pass_through_args: Option<Vec<String>>,\n    },\n    Create {\n        json: bool,\n        registry: Option<String>,\n        cidr: Option<Vec<String>>,\n        readonly: bool,\n        pass_through_args: Option<Vec<String>>,\n    },\n    Revoke {\n        token: String,\n        registry: Option<String>,\n        pass_through_args: Option<Vec<String>>,\n    },\n}\n\nimpl PackageManager {\n    /// Run the token command with the package manager.\n    #[must_use]\n    pub async fn run_token_command(\n        &self,\n        subcommand: &TokenSubcommand,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_token_command(subcommand);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the token command.\n    /// All package managers delegate to npm token.\n    #[must_use]\n    pub fn resolve_token_command(&self, subcommand: &TokenSubcommand) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"token\".into());\n\n        match subcommand {\n            TokenSubcommand::List { json, registry, pass_through_args } => {\n                args.push(\"list\".into());\n\n                if *json {\n                    args.push(\"--json\".into());\n                }\n\n                if let Some(registry_value) = registry {\n                    args.push(\"--registry\".into());\n                    args.push(registry_value.clone());\n                }\n\n                if let Some(pass_through) = pass_through_args {\n                    args.extend_from_slice(pass_through);\n                }\n            }\n            TokenSubcommand::Create { json, registry, cidr, readonly, pass_through_args } => {\n                args.push(\"create\".into());\n\n                if *json {\n                    args.push(\"--json\".into());\n                }\n\n                if let Some(registry_value) = registry {\n                    args.push(\"--registry\".into());\n                    args.push(registry_value.clone());\n                }\n\n                if let Some(cidr_values) = cidr {\n                    for cidr_value in cidr_values {\n                        args.push(\"--cidr\".into());\n                        args.push(cidr_value.clone());\n                    }\n                }\n\n                if *readonly {\n                    args.push(\"--readonly\".into());\n                }\n\n                if let Some(pass_through) = pass_through_args {\n                    args.extend_from_slice(pass_through);\n                }\n            }\n            TokenSubcommand::Revoke { token, registry, pass_through_args } => {\n                args.push(\"revoke\".into());\n                args.push(token.clone());\n\n                if let Some(registry_value) = registry {\n                    args.push(\"--registry\".into());\n                    args.push(registry_value.clone());\n                }\n\n                if let Some(pass_through) = pass_through_args {\n                    args.extend_from_slice(pass_through);\n                }\n            }\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_token_list() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_token_command(&TokenSubcommand::List {\n            json: false,\n            registry: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"token\", \"list\"]);\n    }\n\n    #[test]\n    fn test_token_create() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_token_command(&TokenSubcommand::Create {\n            json: false,\n            registry: None,\n            cidr: None,\n            readonly: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"token\", \"create\"]);\n    }\n\n    #[test]\n    fn test_token_create_with_flags() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_token_command(&TokenSubcommand::Create {\n            json: true,\n            registry: Some(\"https://registry.npmjs.org\".to_string()),\n            cidr: Some(vec![\"192.168.1.0/24\".to_string(), \"10.0.0.0/8\".to_string()]),\n            readonly: true,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(\n            result.args,\n            vec![\n                \"token\",\n                \"create\",\n                \"--json\",\n                \"--registry\",\n                \"https://registry.npmjs.org\",\n                \"--cidr\",\n                \"192.168.1.0/24\",\n                \"--cidr\",\n                \"10.0.0.0/8\",\n                \"--readonly\",\n            ]\n        );\n    }\n\n    #[test]\n    fn test_token_revoke() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_token_command(&TokenSubcommand::Revoke {\n            token: \"abc123\".to_string(),\n            registry: None,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"token\", \"revoke\", \"abc123\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/unlink.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the unlink command.\n#[derive(Debug, Default)]\npub struct UnlinkCommandOptions<'a> {\n    pub package: Option<&'a str>,\n    pub recursive: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the unlink command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_unlink_command(\n        &self,\n        options: &UnlinkCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_unlink_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the unlink command.\n    #[must_use]\n    pub fn resolve_unlink_command(&self, options: &UnlinkCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"unlink\".into());\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                args.push(\"unlink\".into());\n\n                if options.recursive {\n                    args.push(\"--all\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"unlink\".into());\n\n                if options.recursive {\n                    output::warn(\"npm doesn't support --recursive for unlink command\");\n                }\n            }\n        }\n\n        // Add package if specified\n        if let Some(package) = options.package {\n            args.push(package.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_unlink_no_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"unlink\"]);\n    }\n\n    #[test]\n    fn test_pnpm_unlink_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions {\n            package: Some(\"react\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"unlink\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_unlink_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions {\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"unlink\", \"--recursive\"]);\n    }\n\n    #[test]\n    fn test_pnpm_unlink_package_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions {\n            package: Some(\"react\"),\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"unlink\", \"--recursive\", \"react\"]);\n    }\n\n    #[test]\n    fn test_yarn_unlink_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"unlink\"]);\n    }\n\n    #[test]\n    fn test_yarn_unlink_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions {\n            package: Some(\"react\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"unlink\", \"react\"]);\n    }\n\n    #[test]\n    fn test_yarn_unlink_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions {\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"unlink\", \"--all\"]);\n    }\n\n    #[test]\n    fn test_npm_unlink_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions { ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"unlink\"]);\n    }\n\n    #[test]\n    fn test_npm_unlink_package() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_unlink_command(&UnlinkCommandOptions {\n            package: Some(\"react\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"unlink\", \"react\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/update.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the update command.\n#[derive(Debug, Default)]\npub struct UpdateCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub latest: bool,\n    pub global: bool,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub dev: bool,\n    pub prod: bool,\n    pub interactive: bool,\n    pub no_optional: bool,\n    pub no_save: bool,\n    pub workspace_only: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the update command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_update_command(\n        &self,\n        options: &UpdateCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_update_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the update command.\n    #[must_use]\n    pub fn resolve_update_command(&self, options: &UpdateCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        // global packages should use npm cli only\n        if options.global {\n            bin_name = \"npm\".into();\n            args.push(\"update\".into());\n            args.push(\"--global\".into());\n            if let Some(pass_through_args) = options.pass_through_args {\n                args.extend_from_slice(pass_through_args);\n            }\n            args.extend_from_slice(options.packages);\n\n            return ResolveCommandResult { bin_path: bin_name, args, envs };\n        }\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"update\".into());\n\n                if options.latest {\n                    args.push(\"--latest\".into());\n                }\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n                if options.interactive {\n                    args.push(\"--interactive\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n                if options.no_save {\n                    args.push(\"--no-save\".into());\n                }\n                if options.workspace_only {\n                    args.push(\"--workspace\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n\n                // Determine yarn version\n                let is_yarn_v1 = self.version.starts_with(\"1.\");\n\n                if is_yarn_v1 {\n                    // yarn@1: yarn upgrade [--latest]\n                    if let Some(filters) = options.filters {\n                        args.push(\"workspace\".into());\n                        args.push(filters[0].clone());\n                    }\n                    args.push(\"upgrade\".into());\n                    if options.latest {\n                        args.push(\"--latest\".into());\n                    }\n                } else {\n                    // yarn@2+: yarn up (already updates to latest by default)\n                    if let Some(filters) = options.filters {\n                        args.push(\"workspaces\".into());\n                        args.push(\"foreach\".into());\n                        args.push(\"--all\".into());\n                        for filter in filters {\n                            args.push(\"--include\".into());\n                            args.push(filter.clone());\n                        }\n                    }\n                    args.push(\"up\".into());\n                    if options.recursive {\n                        args.push(\"--recursive\".into());\n                    }\n                    if options.interactive {\n                        args.push(\"--interactive\".into());\n                    }\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"update\".into());\n\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                if options.workspace_root || options.recursive {\n                    args.push(\"--include-workspace-root\".into());\n                }\n                if options.recursive {\n                    args.push(\"--workspaces\".into());\n                }\n                if options.dev {\n                    args.push(\"--include=dev\".into());\n                }\n                if options.prod {\n                    args.push(\"--include=prod\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n                if options.no_save {\n                    args.push(\"--no-save\".into());\n                }\n\n                // npm doesn't have --latest flag\n                // Warn user or handle differently\n                if options.latest {\n                    output::warn(\n                        \"npm doesn't support --latest flag. Updating within semver range only.\",\n                    );\n                }\n\n                // npm doesn't support interactive mode\n                if options.interactive {\n                    output::warn(\"npm doesn't support interactive mode. Running standard update.\");\n                }\n            }\n        }\n\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n        args.extend_from_slice(options.packages);\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_basic_update() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            latest: false,\n            global: false,\n            recursive: false,\n            filters: None,\n            workspace_root: false,\n            dev: false,\n            prod: false,\n            interactive: false,\n            no_optional: false,\n            no_save: false,\n            workspace_only: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"update\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_update_latest() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            latest: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"update\", \"--latest\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_update_all() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            latest: false,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"update\"]);\n    }\n\n    #[test]\n    fn test_pnpm_update_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"update\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--recursive\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_interactive() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            interactive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--interactive\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_dev_only() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            dev: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--dev\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_no_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            no_optional: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--no-optional\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_no_save() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            no_save: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--no-save\", \"react\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_workspace_only() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"@myorg/utils\".to_string()],\n            workspace_only: true,\n            filters: Some(&[\"app\".to_string()]),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"update\", \"--workspace\", \"@myorg/utils\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_yarn_v1_basic_update() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"upgrade\", \"react\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_v1_update_latest() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            latest: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"upgrade\", \"--latest\", \"react\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_v1_update_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"workspace\", \"app\", \"upgrade\", \"react\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_v4_basic_update() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"up\", \"react\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_v4_update_interactive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            interactive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"up\", \"--interactive\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_v4_update_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            ..Default::default()\n        });\n        assert_eq!(\n            result.args,\n            vec![\"workspaces\", \"foreach\", \"--all\", \"--include\", \"app\", \"up\", \"react\"]\n        );\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_yarn_v4_update_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"up\", \"--recursive\"]);\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n\n    #[test]\n    fn test_npm_basic_update() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"react\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_update_all() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm\n            .resolve_update_command(&UpdateCommandOptions { packages: &[], ..Default::default() });\n        assert_eq!(result.args, vec![\"update\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_update_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            filters: Some(&[\"app\".to_string()]),\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--workspace\", \"app\", \"react\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_update_recursive() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            recursive: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--include-workspace-root\", \"--workspaces\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_update_dev_only() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            dev: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--include=dev\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_update_no_optional() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[],\n            no_optional: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--no-optional\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_npm_update_no_save() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            no_save: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--no-save\", \"react\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_global_update() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"typescript\".to_string()],\n            global: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--global\", \"typescript\"]);\n        assert_eq!(result.bin_path, \"npm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_multiple_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string(), \"react-dom\".to_string(), \"vite\".to_string()],\n            latest: true,\n            ..Default::default()\n        });\n        assert_eq!(result.args, vec![\"update\", \"--latest\", \"react\", \"react-dom\", \"vite\"]);\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_pnpm_update_complex() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"react\".to_string()],\n            latest: true,\n            recursive: true,\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            dev: true,\n            interactive: true,\n            ..Default::default()\n        });\n        assert_eq!(\n            result.args,\n            vec![\n                \"--filter\",\n                \"app\",\n                \"--filter\",\n                \"web\",\n                \"update\",\n                \"--latest\",\n                \"--recursive\",\n                \"--dev\",\n                \"--interactive\",\n                \"react\"\n            ]\n        );\n        assert_eq!(result.bin_path, \"pnpm\");\n    }\n\n    #[test]\n    fn test_yarn_v4_update_multiple_filters() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_update_command(&UpdateCommandOptions {\n            packages: &[\"lodash\".to_string()],\n            filters: Some(&[\"app\".to_string(), \"web\".to_string()]),\n            ..Default::default()\n        });\n        assert_eq!(\n            result.args,\n            vec![\n                \"workspaces\",\n                \"foreach\",\n                \"--all\",\n                \"--include\",\n                \"app\",\n                \"--include\",\n                \"web\",\n                \"up\",\n                \"lodash\"\n            ]\n        );\n        assert_eq!(result.bin_path, \"yarn\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/view.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{PackageManager, ResolveCommandResult, format_path_env};\n\n/// Options for the view command.\n#[derive(Debug, Default)]\npub struct ViewCommandOptions<'a> {\n    pub package: &'a str,\n    pub field: Option<&'a str>,\n    pub json: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the view command with the package manager.\n    #[must_use]\n    pub async fn run_view_command(\n        &self,\n        options: &ViewCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_view_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the view command.\n    /// All package managers delegate to npm view (pnpm and yarn use npm internally).\n    #[must_use]\n    pub fn resolve_view_command(&self, options: &ViewCommandOptions) -> ResolveCommandResult {\n        let bin_name: String = \"npm\".to_string();\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        args.push(\"view\".into());\n\n        args.push(options.package.to_string());\n\n        if let Some(field) = options.field {\n            args.push(field.to_string());\n        }\n\n        if options.json {\n            args.push(\"--json\".into());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n    use crate::package_manager::PackageManagerType;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_view_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_view_command(&ViewCommandOptions {\n            package: \"react\",\n            field: None,\n            json: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"view\", \"react\"]);\n    }\n\n    #[test]\n    fn test_npm_view() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_view_command(&ViewCommandOptions {\n            package: \"react\",\n            field: Some(\"version\"),\n            json: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"view\", \"react\", \"version\"]);\n    }\n\n    #[test]\n    fn test_yarn_view_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_view_command(&ViewCommandOptions {\n            package: \"lodash\",\n            field: None,\n            json: true,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"view\", \"lodash\", \"--json\"]);\n    }\n\n    #[test]\n    fn test_view_with_nested_field() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_view_command(&ViewCommandOptions {\n            package: \"react\",\n            field: Some(\"dist.tarball\"),\n            json: false,\n            pass_through_args: None,\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"view\", \"react\", \"dist.tarball\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/whoami.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the whoami command.\n#[derive(Debug)]\npub struct WhoamiCommandOptions<'a> {\n    pub registry: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the whoami command with the package manager.\n    /// Returns ExitStatus with success (0) if the command is not supported.\n    #[must_use]\n    pub async fn run_whoami_command(\n        &self,\n        options: &WhoamiCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let Some(resolve_command) = self.resolve_whoami_command(options) else {\n            return Ok(ExitStatus::default());\n        };\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the whoami command.\n    /// Returns None if the command is not supported by the package manager.\n    #[must_use]\n    pub fn resolve_whoami_command(\n        &self,\n        options: &WhoamiCommandOptions,\n    ) -> Option<ResolveCommandResult> {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        let bin_name: String;\n\n        match self.client {\n            PackageManagerType::Pnpm | PackageManagerType::Npm => {\n                // pnpm delegates whoami to npm\n                bin_name = \"npm\".into();\n                args.push(\"whoami\".into());\n            }\n            PackageManagerType::Yarn => {\n                let is_yarn1 = self.version.starts_with(\"1.\");\n\n                if is_yarn1 {\n                    output::warn(\"yarn v1 does not support the whoami command\");\n                    return None;\n                }\n\n                bin_name = \"yarn\".into();\n                args.push(\"npm\".into());\n                args.push(\"whoami\".into());\n            }\n        }\n\n        if let Some(registry) = options.registry {\n            args.push(\"--registry\".into());\n            args.push(registry.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        Some(ResolveCommandResult { bin_path: bin_name, args, envs })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let _temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(_temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_npm_whoami() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_whoami_command(&WhoamiCommandOptions {\n            registry: None,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"whoami\"]);\n    }\n\n    #[test]\n    fn test_pnpm_whoami_uses_npm() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let result = pm.resolve_whoami_command(&WhoamiCommandOptions {\n            registry: None,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"whoami\"]);\n    }\n\n    #[test]\n    fn test_yarn1_whoami_not_supported() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let result = pm.resolve_whoami_command(&WhoamiCommandOptions {\n            registry: None,\n            pass_through_args: None,\n        });\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_yarn2_whoami() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let result = pm.resolve_whoami_command(&WhoamiCommandOptions {\n            registry: None,\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"npm\", \"whoami\"]);\n    }\n\n    #[test]\n    fn test_whoami_with_registry() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let result = pm.resolve_whoami_command(&WhoamiCommandOptions {\n            registry: Some(\"https://registry.example.com\"),\n            pass_through_args: None,\n        });\n        assert!(result.is_some());\n        let result = result.unwrap();\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"whoami\", \"--registry\", \"https://registry.example.com\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/commands/why.rs",
    "content": "use std::{collections::HashMap, process::ExitStatus};\n\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse vite_shared::output;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,\n};\n\n/// Options for the why command.\n#[derive(Debug, Default)]\npub struct WhyCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub json: bool,\n    pub long: bool,\n    pub parseable: bool,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub prod: bool,\n    pub dev: bool,\n    pub depth: Option<u32>,\n    pub no_optional: bool,\n    pub global: bool,\n    pub exclude_peers: bool,\n    pub find_by: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the why command with the package manager.\n    /// Return the exit status of the command.\n    #[must_use]\n    pub async fn run_why_command(\n        &self,\n        options: &WhyCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_why_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the why command.\n    #[must_use]\n    pub fn resolve_why_command(&self, options: &WhyCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                args.push(\"why\".into());\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if options.long {\n                    args.push(\"--long\".into());\n                }\n\n                if options.parseable {\n                    args.push(\"--parseable\".into());\n                }\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n\n                if let Some(depth) = options.depth {\n                    args.push(\"--depth\".into());\n                    args.push(depth.to_string());\n                }\n\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n\n                if options.global {\n                    args.push(\"--global\".into());\n                }\n\n                if options.exclude_peers {\n                    args.push(\"--exclude-peers\".into());\n                }\n\n                if let Some(find_by) = options.find_by {\n                    args.push(\"--find-by\".into());\n                    args.push(find_by.to_string());\n                }\n\n                // Add packages (pnpm supports multiple packages)\n                args.extend_from_slice(options.packages);\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n\n                args.push(\"why\".into());\n\n                // yarn only supports single package\n                if options.packages.len() > 1 {\n                    output::warn(\n                        \"yarn only supports checking one package at a time, using first package\",\n                    );\n                }\n                args.push(options.packages[0].clone());\n\n                // yarn@2+ supports --recursive\n                if options.recursive && !self.version.starts_with(\"1.\") {\n                    args.push(\"--recursive\".into());\n                }\n\n                // yarn@2+: Add --peers by default unless --exclude-peers is set\n                if !self.version.starts_with(\"1.\") && !options.exclude_peers {\n                    args.push(\"--peers\".into());\n                }\n\n                // Warn about unsupported flags\n                if options.json {\n                    output::warn(\"--json not supported by yarn\");\n                }\n                if options.long {\n                    output::warn(\"--long not supported by yarn\");\n                }\n                if options.parseable {\n                    output::warn(\"--parseable not supported by yarn\");\n                }\n                if let Some(filters) = options.filters {\n                    if !filters.is_empty() {\n                        output::warn(\"--filter not supported by yarn\");\n                    }\n                }\n                if options.prod || options.dev {\n                    output::warn(\"--prod/--dev not supported by yarn\");\n                }\n                if options.find_by.is_some() {\n                    output::warn(\"--find-by not supported by yarn\");\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n\n                // npm uses 'explain' as primary command\n                args.push(\"explain\".into());\n\n                // npm: --workspace comes after command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                // Add packages (npm supports multiple packages)\n                args.extend_from_slice(options.packages);\n\n                // Warn about pnpm-specific flags\n                if options.long {\n                    output::warn(\"--long not supported by npm\");\n                }\n                if options.parseable {\n                    output::warn(\"--parseable not supported by npm\");\n                }\n                if options.prod || options.dev {\n                    output::warn(\"--prod/--dev not supported by npm\");\n                }\n                if options.depth.is_some() {\n                    output::warn(\"--depth not supported by npm\");\n                }\n                if options.find_by.is_some() {\n                    output::warn(\"--find-by not supported by npm\");\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::{TempDir, tempdir};\n    use vite_path::AbsolutePathBuf;\n    use vite_str::Str;\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let install_dir = temp_dir_path.join(\"install\");\n\n        PackageManager {\n            client: pm_type,\n            package_name: pm_type.to_string().into(),\n            version: Str::from(version),\n            hash: None,\n            bin_name: pm_type.to_string().into(),\n            workspace_root: temp_dir_path.clone(),\n            is_monorepo: false,\n            install_dir,\n        }\n    }\n\n    #[test]\n    fn test_pnpm_why_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm\n            .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"why\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_why_multiple_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"react\".to_string(), \"lodash\".to_string()];\n        let result = pm\n            .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"why\", \"react\", \"lodash\"]);\n    }\n\n    #[test]\n    fn test_pnpm_why_json() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm.resolve_why_command(&WhyCommandOptions {\n            packages: &packages,\n            json: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"why\", \"--json\", \"react\"]);\n    }\n\n    #[test]\n    fn test_npm_explain_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm\n            .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"explain\", \"react\"]);\n    }\n\n    #[test]\n    fn test_npm_explain_multiple_packages() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let packages = vec![\"react\".to_string(), \"lodash\".to_string()];\n        let result = pm\n            .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"explain\", \"react\", \"lodash\"]);\n    }\n\n    #[test]\n    fn test_npm_explain_with_workspace() {\n        let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_why_command(&WhyCommandOptions {\n            packages: &packages,\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"npm\");\n        assert_eq!(result.args, vec![\"explain\", \"--workspace\", \"app\", \"react\"]);\n    }\n\n    #[test]\n    fn test_yarn_why_basic() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm\n            .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"why\", \"react\", \"--peers\"]);\n    }\n\n    #[test]\n    fn test_yarn_why_with_exclude_peers() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm.resolve_why_command(&WhyCommandOptions {\n            packages: &packages,\n            exclude_peers: true,\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"yarn\");\n        assert_eq!(result.args, vec![\"why\", \"react\"]);\n    }\n\n    #[test]\n    fn test_yarn1_why_no_peers() {\n        let pm = create_mock_package_manager(PackageManagerType::Yarn, \"1.22.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm\n            .resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });\n        assert_eq!(result.bin_path, \"yarn\");\n        // yarn@1 doesn't support --peers\n        assert_eq!(result.args, vec![\"why\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_why_with_filter() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let filters = vec![\"app\".to_string()];\n        let result = pm.resolve_why_command(&WhyCommandOptions {\n            packages: &packages,\n            filters: Some(&filters),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"--filter\", \"app\", \"why\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_why_with_depth() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm.resolve_why_command(&WhyCommandOptions {\n            packages: &packages,\n            depth: Some(3),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"why\", \"--depth\", \"3\", \"react\"]);\n    }\n\n    #[test]\n    fn test_pnpm_why_with_find_by() {\n        let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n        let packages = vec![\"react\".to_string()];\n        let result = pm.resolve_why_command(&WhyCommandOptions {\n            packages: &packages,\n            find_by: Some(\"customFinder\"),\n            ..Default::default()\n        });\n        assert_eq!(result.bin_path, \"pnpm\");\n        assert_eq!(result.args, vec![\"why\", \"--find-by\", \"customFinder\", \"react\"]);\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/config.rs",
    "content": "use vite_shared::EnvConfig;\n\n/// Get the configured NPM registry URL.\npub fn npm_registry() -> String {\n    EnvConfig::get().npm_registry.clone()\n}\n\n/// Get the tgz url of a npm package\npub fn get_npm_package_tgz_url(name: &str, version: &str) -> String {\n    let registry = npm_registry();\n    // convert `@scope/name` to `name`\n    let filename = name.split('/').next_back().unwrap_or(name);\n    format!(\"{}/{}/-/{}-{}.tgz\", registry, name, filename, version)\n}\n\npub fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> String {\n    let registry = npm_registry();\n    format!(\"{}/{}/{}\", registry, name, version_or_tag)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_npm_registry_default() {\n        EnvConfig::test_scope(EnvConfig::for_test(), || {\n            assert_eq!(npm_registry(), \"https://registry.npmjs.org\");\n        });\n    }\n\n    #[test]\n    fn test_npm_registry_custom() {\n        EnvConfig::test_scope(\n            EnvConfig {\n                npm_registry: \"https://registry.npmmirror.com\".into(),\n                ..EnvConfig::for_test()\n            },\n            || {\n                assert_eq!(npm_registry(), \"https://registry.npmmirror.com\");\n            },\n        );\n    }\n\n    #[test]\n    fn test_npm_tgz_url() {\n        EnvConfig::test_scope(EnvConfig::for_test(), || {\n            assert_eq!(\n                get_npm_package_tgz_url(\"vite\", \"7.1.3\"),\n                \"https://registry.npmjs.org/vite/-/vite-7.1.3.tgz\"\n            );\n            assert_eq!(\n                get_npm_package_tgz_url(\"@vitejs/release-scripts\", \"1.6.0\"),\n                \"https://registry.npmjs.org/@vitejs/release-scripts/-/release-scripts-1.6.0.tgz\"\n            );\n        });\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/lib.rs",
    "content": "pub mod commands;\npub mod config;\npub mod package_manager;\npub mod request;\nmod shim;\n\npub use package_manager::{\n    PackageManager, PackageManagerType, download_package_manager,\n    get_package_manager_type_and_version,\n};\n"
  },
  {
    "path": "crates/vite_install/src/main.rs",
    "content": "use vite_error::Error;\nuse vite_install::PackageManager;\nuse vite_path::current_dir;\n\n#[tokio::main]\nasync fn main() -> Result<(), Error> {\n    let current_dir = current_dir()?;\n    let package_manager = PackageManager::builder(&current_dir).build().await?;\n    println!(\"Package manager: {package_manager:#?} for {current_dir:?}\");\n\n    let resolve_command = package_manager.resolve_install_command(&vec![]);\n    println!(\"Resolve command: {resolve_command:#?}\");\n\n    Ok(())\n}\n"
  },
  {
    "path": "crates/vite_install/src/package_manager.rs",
    "content": "use std::{\n    collections::HashMap,\n    env, fmt,\n    fs::{self, File},\n    io::{self, BufReader, IsTerminal, Write},\n    path::Path,\n};\n\nuse crossterm::{\n    cursor,\n    event::{self, Event, KeyCode, KeyEvent},\n    execute,\n    style::{Color, Print, ResetColor, SetForegroundColor},\n    terminal,\n};\nuse semver::{Version, VersionReq};\nuse serde::{Deserialize, Serialize};\nuse tokio::fs::remove_dir_all;\nuse vite_error::Error;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_str::Str;\n#[cfg(test)]\nuse vite_workspace::find_package_root;\nuse vite_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root, load_package_graph};\n\nuse crate::{\n    config::{get_npm_package_tgz_url, get_npm_package_version_url},\n    request::{HttpClient, download_and_extract_tgz_with_hash},\n    shim,\n};\n\n#[derive(Serialize, Deserialize, Clone, Default)]\n#[serde(rename_all = \"camelCase\")]\nstruct PackageJson {\n    #[serde(default)]\n    pub version: Str,\n    #[serde(default)]\n    pub package_manager: Str,\n}\n\n/// The package manager type.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum PackageManagerType {\n    Pnpm,\n    Yarn,\n    Npm,\n}\n\nimpl fmt::Display for PackageManagerType {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::Pnpm => write!(f, \"pnpm\"),\n            Self::Yarn => write!(f, \"yarn\"),\n            Self::Npm => write!(f, \"npm\"),\n        }\n    }\n}\n\n// TODO(@fengmk2): should move ResolveCommandResult to vite-common crate\n#[derive(Debug)]\npub struct ResolveCommandResult {\n    pub bin_path: String,\n    pub args: Vec<String>,\n    pub envs: HashMap<String, String>,\n}\n\n/// The package manager.\n/// Use `PackageManager::builder()` to create a package manager.\n/// Then use `PackageManager::resolve_command()` to resolve the command result.\n#[derive(Debug)]\npub struct PackageManager {\n    pub client: PackageManagerType,\n    pub package_name: Str,\n    pub version: Str,\n    pub hash: Option<Str>,\n    pub bin_name: Str,\n    pub workspace_root: AbsolutePathBuf,\n    /// Whether the workspace is a monorepo.\n    pub is_monorepo: bool,\n    pub install_dir: AbsolutePathBuf,\n}\n\n#[derive(Debug)]\npub struct PackageManagerBuilder {\n    client_override: Option<PackageManagerType>,\n    cwd: AbsolutePathBuf,\n}\n\nimpl PackageManagerBuilder {\n    pub fn new(cwd: impl AsRef<AbsolutePath>) -> Self {\n        Self { client_override: None, cwd: cwd.as_ref().to_absolute_path_buf() }\n    }\n\n    #[must_use]\n    pub const fn package_manager_type(mut self, package_manager_type: PackageManagerType) -> Self {\n        self.client_override = Some(package_manager_type);\n        self\n    }\n\n    /// Build the package manager.\n    /// Detect the package manager from the current working directory.\n    pub async fn build(&self) -> Result<PackageManager, Error> {\n        let (workspace_root, _cwd) = find_workspace_root(&self.cwd)?;\n        let (package_manager_type, version_or_latest, hash) =\n            get_package_manager_type_and_version(&workspace_root, self.client_override)?;\n\n        // only download the package manager if it's not already downloaded\n        let (install_dir, package_name, version) =\n            download_package_manager(package_manager_type, &version_or_latest, hash.as_deref())\n                .await?;\n\n        if version_or_latest != version {\n            // auto set `packageManager` field in package.json\n            let package_json_path = workspace_root.path.join(\"package.json\");\n            set_package_manager_field(&package_json_path, package_manager_type, &version).await?;\n        }\n\n        let is_monorepo = matches!(\n            workspace_root.workspace_file,\n            WorkspaceFile::PnpmWorkspaceYaml(_) | WorkspaceFile::NpmWorkspaceJson(_)\n        );\n\n        Ok(PackageManager {\n            client: package_manager_type,\n            package_name,\n            version,\n            hash,\n            bin_name: package_manager_type.to_string().into(),\n            workspace_root: workspace_root.path.to_absolute_path_buf(),\n            is_monorepo,\n            install_dir,\n        })\n    }\n\n    /// Build the package manager with default package manager.\n    /// If the package manager is not specified, prompt the user to select a package manager.\n    pub async fn build_with_default(&self) -> Result<PackageManager, Error> {\n        let package_manager = match self.build().await {\n            Ok(pm) => pm,\n            Err(Error::UnrecognizedPackageManager) => {\n                // Prompt user to select a package manager\n                let selected_type = prompt_package_manager_selection()?;\n                PackageManagerBuilder::new(&self.cwd)\n                    .package_manager_type(selected_type)\n                    .build()\n                    .await?\n            }\n            Err(e) => return Err(e),\n        };\n        Ok(package_manager)\n    }\n}\n\nimpl PackageManager {\n    pub fn builder(cwd: impl AsRef<AbsolutePath>) -> PackageManagerBuilder {\n        PackageManagerBuilder::new(cwd)\n    }\n\n    #[must_use]\n    pub fn get_bin_prefix(&self) -> AbsolutePathBuf {\n        self.install_dir.join(\"bin\")\n    }\n\n    #[must_use]\n    pub fn get_fingerprint_ignores(&self) -> Result<Vec<Str>, Error> {\n        let mut ignores: Vec<Str> = vec![\n            // ignore all files by default, the package manager will traverse all subdirectories\n            \"**/*\".into(),\n            // keep all package.json files except under node_modules\n            \"!**/package.json\".into(),\n            \"!**/.npmrc\".into(),\n        ];\n        match self.client {\n            PackageManagerType::Pnpm => {\n                ignores.push(\"!**/pnpm-workspace.yaml\".into());\n                ignores.push(\"!**/pnpm-lock.yaml\".into());\n                // https://pnpm.io/pnpmfile\n                ignores.push(\"!**/.pnpmfile.cjs\".into());\n                ignores.push(\"!**/pnpmfile.cjs\".into());\n                // pnpm support Plug'n'Play https://pnpm.io/blog/2020/10/17/node-modules-configuration-options-with-pnpm#plugnplay-the-strictest-configuration\n                ignores.push(\"!**/.pnp.cjs\".into());\n            }\n            PackageManagerType::Yarn => {\n                ignores.push(\"!**/.yarnrc\".into()); // yarn 1.x\n                ignores.push(\"!**/.yarnrc.yml\".into()); // yarn 2.x\n                ignores.push(\"!**/yarn.config.cjs\".into()); // yarn 2.x\n                ignores.push(\"!**/yarn.lock\".into());\n                // .yarn/patches, .yarn/releases\n                ignores.push(\"!**/.yarn/**/*\".into());\n                // .pnp.cjs https://yarnpkg.com/features/pnp\n                ignores.push(\"!**/.pnp.cjs\".into());\n            }\n            PackageManagerType::Npm => {\n                ignores.push(\"!**/package-lock.json\".into());\n                ignores.push(\"!**/npm-shrinkwrap.json\".into());\n            }\n        }\n\n        // if the workspace is a monorepo, keep workspace packages' parent directories to watch for new packages being added\n        if self.is_monorepo {\n            // TODO(@fengmk2): should use a more efficient way to get the workspace packages parent directories\n            let (workspace_root_info, _) = find_workspace_root(&self.workspace_root)?;\n            let package_graph = load_package_graph(&workspace_root_info)?;\n            for node_index in package_graph.node_indices() {\n                let package_info = &package_graph[node_index];\n                if let Some(parent_path) = package_info.path.as_path().parent() {\n                    let rule: Str = format!(\"!{}\", parent_path.display()).into();\n                    // check if the rule is already in the ignores\n                    if ignores.contains(&rule) {\n                        continue;\n                    }\n                    ignores.push(rule);\n                }\n            }\n        }\n\n        // ignore all files under node_modules\n        // e.g. node_modules/mqtt/package.json\n        ignores.push(\"**/node_modules/**/*\".into());\n        // keep the node_modules directory\n        ignores.push(\"!**/node_modules\".into());\n        // keep the scoped directory\n        ignores.push(\"!**/node_modules/@*\".into());\n        // ignore all patterns under nested node_modules\n        // e.g. node_modules/mqtt/node_modules/mqtt-packet/node_modules\n        ignores.push(\"**/node_modules/**/node_modules/**\".into());\n\n        Ok(ignores)\n    }\n}\n\n/// Get the package manager name, version and optional hash from the workspace root.\npub fn get_package_manager_type_and_version(\n    workspace_root: &WorkspaceRoot,\n    default: Option<PackageManagerType>,\n) -> Result<(PackageManagerType, Str, Option<Str>), Error> {\n    // check packageManager field in package.json\n    let package_json_path = workspace_root.path.join(\"package.json\");\n    if let Some(file) = open_exists_file(&package_json_path)? {\n        let package_json: PackageJson = serde_json::from_reader(BufReader::new(&file))?;\n        if !package_json.package_manager.is_empty()\n            && let Some((name, version_with_hash)) = package_json.package_manager.split_once('@')\n        {\n            // Parse version and optional hash (format: version+sha512.hash)\n            let (version, hash) = if let Some((ver, hash_part)) = version_with_hash.split_once('+')\n            {\n                (ver, Some(hash_part.into()))\n            } else {\n                (version_with_hash, None)\n            };\n\n            // check if the version is a valid semver\n            semver::Version::parse(version).map_err(|_| Error::PackageManagerVersionInvalid {\n                name: name.into(),\n                version: version.into(),\n                package_json_path: package_json_path.to_absolute_path_buf(),\n            })?;\n            match name {\n                \"pnpm\" => return Ok((PackageManagerType::Pnpm, version.into(), hash)),\n                \"yarn\" => return Ok((PackageManagerType::Yarn, version.into(), hash)),\n                \"npm\" => return Ok((PackageManagerType::Npm, version.into(), hash)),\n                _ => return Err(Error::UnsupportedPackageManager(name.into())),\n            }\n        }\n    }\n\n    // TODO(@fengmk2): check devEngines.packageManager field in package.json\n\n    let version = Str::from(\"latest\");\n    // if pnpm-workspace.yaml exists, use pnpm@latest\n    if matches!(workspace_root.workspace_file, WorkspaceFile::PnpmWorkspaceYaml(_)) {\n        return Ok((PackageManagerType::Pnpm, version, None));\n    }\n\n    // if pnpm-lock.yaml exists, use pnpm@latest\n    let pnpm_lock_yaml_path = workspace_root.path.join(\"pnpm-lock.yaml\");\n    if is_exists_file(&pnpm_lock_yaml_path)? {\n        return Ok((PackageManagerType::Pnpm, version, None));\n    }\n\n    // if yarn.lock or .yarnrc.yml exists, use yarn@latest\n    let yarn_lock_path = workspace_root.path.join(\"yarn.lock\");\n    let yarnrc_yml_path = workspace_root.path.join(\".yarnrc.yml\");\n    if is_exists_file(&yarn_lock_path)? || is_exists_file(&yarnrc_yml_path)? {\n        return Ok((PackageManagerType::Yarn, version, None));\n    }\n\n    // if package-lock.json exists, use npm@latest\n    let package_lock_json_path = workspace_root.path.join(\"package-lock.json\");\n    if is_exists_file(&package_lock_json_path)? {\n        return Ok((PackageManagerType::Npm, version, None));\n    }\n\n    // if .pnpmfile.cjs exists, use pnpm@latest\n    let pnpmfile_cjs_path = workspace_root.path.join(\".pnpmfile.cjs\");\n    if is_exists_file(&pnpmfile_cjs_path)? {\n        return Ok((PackageManagerType::Pnpm, version, None));\n    }\n    // if legacy pnpmfile.cjs exists, use pnpm@latest\n    // https://newreleases.io/project/npm/pnpm/release/6.0.0\n    let legacy_pnpmfile_cjs_path = workspace_root.path.join(\"pnpmfile.cjs\");\n    if is_exists_file(&legacy_pnpmfile_cjs_path)? {\n        return Ok((PackageManagerType::Pnpm, version, None));\n    }\n\n    // if yarn.config.cjs exists, use yarn@latest (yarn 2.0+)\n    let yarn_config_cjs_path = workspace_root.path.join(\"yarn.config.cjs\");\n    if is_exists_file(&yarn_config_cjs_path)? {\n        return Ok((PackageManagerType::Yarn, version, None));\n    }\n\n    // if default is specified, use it\n    if let Some(default) = default {\n        return Ok((default, version, None));\n    }\n\n    // unrecognized package manager, let user specify the package manager\n    Err(Error::UnrecognizedPackageManager)\n}\n\n/// Open the file if it exists, otherwise return None.\nfn open_exists_file(path: impl AsRef<Path>) -> Result<Option<File>, Error> {\n    match File::open(path) {\n        Ok(file) => Ok(Some(file)),\n        // if the file does not exist, return None\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),\n        Err(e) => Err(e.into()),\n    }\n}\n\n/// Check if the file exists.\nfn is_exists_file(path: impl AsRef<Path>) -> Result<bool, Error> {\n    match fs::metadata(path) {\n        Ok(metadata) => Ok(metadata.is_file()),\n        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),\n        Err(e) => Err(e.into()),\n    }\n}\n\nasync fn get_latest_version(package_manager_type: PackageManagerType) -> Result<Str, Error> {\n    let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) {\n        // yarn latest version should use `@yarnpkg/cli-dist` as package name\n        \"@yarnpkg/cli-dist\".to_string()\n    } else {\n        package_manager_type.to_string()\n    };\n    let url = get_npm_package_version_url(&package_name, \"latest\");\n    let package_json: PackageJson = HttpClient::new().get_json(&url).await?;\n    Ok(package_json.version)\n}\n\n/// Download the package manager and extract it to the vite-plus home directory.\n/// Return the install directory, e.g. `$VITE_PLUS_HOME/package_manager/pnpm/10.0.0/pnpm`\npub async fn download_package_manager(\n    package_manager_type: PackageManagerType,\n    version_or_latest: &str,\n    expected_hash: Option<&str>,\n) -> Result<(AbsolutePathBuf, Str, Str), Error> {\n    let version: Str = if version_or_latest == \"latest\" {\n        get_latest_version(package_manager_type).await?\n    } else {\n        version_or_latest.into()\n    };\n\n    let mut package_name: Str = package_manager_type.to_string().into();\n    // handle yarn >= 2.0.0 to use `@yarnpkg/cli-dist` as package name\n    // @see https://github.com/nodejs/corepack/blob/main/config.json#L135\n    if matches!(package_manager_type, PackageManagerType::Yarn) {\n        let version_req = VersionReq::parse(\">=2.0.0\")?;\n        if version_req.matches(&Version::parse(&version)?) {\n            package_name = \"@yarnpkg/cli-dist\".into();\n        }\n    }\n\n    let tgz_url = get_npm_package_tgz_url(&package_name, &version);\n    let home_dir = vite_shared::get_vite_plus_home()?;\n    let bin_name = package_manager_type.to_string();\n    // $VITE_PLUS_HOME/package_manager/pnpm/10.0.0\n    let target_dir = home_dir.join(\"package_manager\").join(&bin_name).join(&version);\n    let install_dir = target_dir.join(&bin_name);\n\n    // If all shims already exist, return the target directory\n    // $VITE_PLUS_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1)\n    let bin_prefix = install_dir.join(\"bin\");\n    let bin_file = bin_prefix.join(&bin_name);\n    if is_exists_file(&bin_file)?\n        && is_exists_file(bin_file.with_extension(\"cmd\"))?\n        && is_exists_file(bin_file.with_extension(\"ps1\"))?\n    {\n        return Ok((install_dir, package_name, version));\n    }\n\n    // $VITE_PLUS_HOME/package_manager/pnpm/{tmp_name}\n    // Use tempfile::TempDir for robust temporary directory creation\n    let parent_dir = target_dir.parent().unwrap();\n    tokio::fs::create_dir_all(parent_dir).await?;\n    let target_dir_tmp = tempfile::tempdir_in(parent_dir)?.path().to_path_buf();\n\n    download_and_extract_tgz_with_hash(&tgz_url, &target_dir_tmp, expected_hash).await.map_err(\n        |err| {\n            // status 404 means the version is not found, convert to PackageManagerVersionNotFound error\n            if let Error::Reqwest(e) = &err\n                && let Some(status) = e.status()\n                && status == reqwest::StatusCode::NOT_FOUND\n            {\n                Error::PackageManagerVersionNotFound {\n                    name: package_manager_type.to_string().into(),\n                    version: version.clone(),\n                    url: tgz_url.into(),\n                }\n            } else {\n                err\n            }\n        },\n    )?;\n\n    // rename $target_dir_tmp/package to $target_dir_tmp/{bin_name}\n    tracing::debug!(\"Rename package dir to {}\", bin_name);\n    tokio::fs::rename(&target_dir_tmp.join(\"package\"), &target_dir_tmp.join(&bin_name)).await?;\n\n    // Use a file-based lock to ensure atomicity of remove + rename operations\n    // This prevents DirectoryNotEmpty error when multiple processes/threads\n    // try to install the same package manager version concurrently.\n    // The lock is automatically skipped on NFS filesystems where locking is unreliable.\n    let lock_path = parent_dir.join(format!(\"{version}.lock\"));\n    tracing::debug!(\"Acquire lock file: {:?}\", lock_path);\n    let lock_file = File::create(lock_path.as_path())?;\n    // Acquire exclusive lock (blocks until available)\n    lock_file.lock()?;\n    tracing::debug!(\"Lock acquired: {:?}\", lock_path);\n\n    // Check again after acquiring the lock, in case another thread completed\n    // the installation while we were downloading\n    if is_exists_file(&bin_file)? {\n        tracing::debug!(\"bin_file already exists after lock acquisition, skip rename\");\n        return Ok((install_dir, package_name, version));\n    }\n\n    // rename $target_dir_tmp to $target_dir\n    tracing::debug!(\"Rename {:?} to {:?}\", target_dir_tmp, target_dir);\n    remove_dir_all_force(&target_dir).await?;\n    tokio::fs::rename(&target_dir_tmp, &target_dir).await?;\n\n    // create shim file\n    tracing::debug!(\"Create shim files for {}\", bin_name);\n    create_shim_files(package_manager_type, &bin_prefix).await?;\n\n    Ok((install_dir, package_name, version))\n}\n\n/// Remove the directory and all its contents.\n/// Ignore the error if the directory is not found.\nasync fn remove_dir_all_force(path: impl AsRef<Path>) -> Result<(), std::io::Error> {\n    let path = path.as_ref();\n    remove_dir_all(path).await.or_else(|e| {\n        if e.kind() == std::io::ErrorKind::NotFound {\n            Ok(())\n        } else {\n            tracing::error!(\"remove_dir_all_force path: {:?} error: {e:?}\", path);\n            Err(e)\n        }\n    })\n}\n\n/// Create shim files for the package manager.\n///\n/// Will automatically create `{cli_name}.cjs`, `{cli_name}.cmd`, `{cli_name}.ps1` files for the package manager.\n/// Example:\n/// - $`bin_prefix/pnpm` -> $`bin_prefix/pnpm.cjs`\n/// - $`bin_prefix/pnpm.cmd` -> $`bin_prefix/pnpm.cjs`\n/// - $`bin_prefix/pnpm.ps1` -> $`bin_prefix/pnpm.cjs`\n/// - $`bin_prefix/pnpx` -> $`bin_prefix/pnpx.cjs`\n/// - $`bin_prefix/pnpx.cmd` -> $`bin_prefix/pnpx.cjs`\n/// - $`bin_prefix/pnpx.ps1` -> $`bin_prefix/pnpx.cjs`\nasync fn create_shim_files(\n    package_manager_type: PackageManagerType,\n    bin_prefix: impl AsRef<AbsolutePath>,\n) -> Result<(), Error> {\n    let mut bin_names: Vec<(&str, &str)> = Vec::new();\n\n    match package_manager_type {\n        PackageManagerType::Pnpm => {\n            bin_names.push((\"pnpm\", \"pnpm\"));\n            bin_names.push((\"pnpx\", \"pnpx\"));\n        }\n        PackageManagerType::Yarn => {\n            // yarn don't have the `npx` like cli, so we don't need to create shim files for it\n            bin_names.push((\"yarn\", \"yarn\"));\n            // but it has alias `yarnpkg`\n            bin_names.push((\"yarnpkg\", \"yarn\"));\n        }\n        PackageManagerType::Npm => {\n            // npm has two cli: bin/npm-cli.js and bin/npx-cli.js\n            bin_names.push((\"npm\", \"npm-cli\"));\n            bin_names.push((\"npx\", \"npx-cli\"));\n        }\n    }\n\n    let bin_prefix = bin_prefix.as_ref();\n    for (bin_name, js_bin_basename) in bin_names {\n        // try .cjs first\n        let mut js_bin_name = format!(\"{js_bin_basename}.cjs\");\n        if !is_exists_file(bin_prefix.join(&js_bin_name))? {\n            // fallback to .js\n            js_bin_name = format!(\"{js_bin_basename}.js\");\n            if !is_exists_file(bin_prefix.join(&js_bin_name))? {\n                continue;\n            }\n        }\n\n        let source_file = bin_prefix.join(js_bin_name);\n        let to_bin = bin_prefix.join(bin_name);\n        shim::write_shims(&source_file, &to_bin).await?;\n    }\n    Ok(())\n}\n\nasync fn set_package_manager_field(\n    package_json_path: impl AsRef<AbsolutePath>,\n    package_manager_type: PackageManagerType,\n    version: &str,\n) -> Result<(), Error> {\n    let package_json_path = package_json_path.as_ref();\n    let package_manager_value = format!(\"{package_manager_type}@{version}\");\n    let mut package_json = if is_exists_file(package_json_path)? {\n        let content = tokio::fs::read(&package_json_path).await?;\n        serde_json::from_slice(&content)?\n    } else {\n        serde_json::json!({})\n    };\n    // use IndexMap to preserve the order of the fields\n    if let Some(package_json) = package_json.as_object_mut() {\n        package_json.insert(\"packageManager\".into(), serde_json::json!(package_manager_value));\n    }\n    let json_string = serde_json::to_string_pretty(&package_json)?;\n    tokio::fs::write(&package_json_path, json_string).await?;\n    tracing::debug!(\n        \"set_package_manager_field: {:?} to {:?}\",\n        package_json_path,\n        package_manager_value\n    );\n    Ok(())\n}\n\npub(crate) use vite_shared::format_path_prepended as format_path_env;\n\n/// Common CI environment variables\nconst CI_ENV_VARS: &[&str] = &[\n    \"CI\",\n    \"CONTINUOUS_INTEGRATION\",\n    \"GITHUB_ACTIONS\",\n    \"GITLAB_CI\",\n    \"CIRCLECI\",\n    \"TRAVIS\",\n    \"JENKINS_URL\",\n    \"BUILDKITE\",\n    \"DRONE\",\n    \"CODEBUILD_BUILD_ID\", // AWS CodeBuild\n    \"TF_BUILD\",           // Azure Pipelines\n];\n\n/// Check if running in a CI environment\nfn is_ci_environment() -> bool {\n    CI_ENV_VARS.iter().any(|key| env::var(key).is_ok())\n}\n\n/// Interactive menu for selecting a package manager with keyboard navigation\nfn interactive_package_manager_menu() -> Result<PackageManagerType, Error> {\n    let options = [\n        (\"pnpm (recommended)\", PackageManagerType::Pnpm),\n        (\"npm\", PackageManagerType::Npm),\n        (\"yarn\", PackageManagerType::Yarn),\n    ];\n\n    let mut selected_index = 0;\n\n    // Print header and instructions with proper line breaks\n    println!(\"\\nNo package manager detected. Please select one:\");\n    println!(\n        \"   Use ↑↓ arrows to navigate, Enter to select, 1-{} for quick selection\",\n        options.len()\n    );\n    println!(\"   Press Esc, q, or Ctrl+C to cancel installation\\n\");\n\n    // Enable raw mode for keyboard input\n    terminal::enable_raw_mode()?;\n\n    // Clear the selection area and hide cursor\n    execute!(io::stdout(), cursor::Hide)?;\n\n    let result = loop {\n        // Display menu with current selection\n        for (i, (name, _)) in options.iter().enumerate() {\n            execute!(io::stdout(), cursor::MoveToColumn(2))?;\n\n            if i == selected_index {\n                // Highlight selected item\n                execute!(\n                    io::stdout(),\n                    SetForegroundColor(Color::Blue),\n                    Print(\"▶ \"),\n                    Print(format!(\"[{}] \", i + 1)),\n                    Print(name),\n                    ResetColor,\n                    Print(\" ← \")\n                )?;\n            } else {\n                execute!(\n                    io::stdout(),\n                    Print(\"  \"),\n                    SetForegroundColor(Color::DarkGrey),\n                    Print(format!(\"[{}] \", i + 1)),\n                    ResetColor,\n                    Print(name),\n                    Print(\"   \")\n                )?;\n            }\n\n            if i < options.len() - 1 {\n                execute!(io::stdout(), Print(\"\\n\"))?;\n            }\n        }\n\n        // Move cursor back up for next iteration\n        if options.len() > 1 {\n            execute!(io::stdout(), cursor::MoveUp((options.len() - 1) as u16))?;\n        }\n\n        // Read keyboard input\n        if let Event::Key(KeyEvent { code, modifiers, .. }) = event::read()? {\n            match code {\n                // Handle Ctrl+C for exit\n                KeyCode::Char('c') if modifiers.contains(event::KeyModifiers::CONTROL) => {\n                    // Clean up terminal before exiting\n                    terminal::disable_raw_mode()?;\n                    execute!(\n                        io::stdout(),\n                        cursor::Show,\n                        cursor::MoveDown(options.len() as u16),\n                        Print(\"\\n\\n\"),\n                        SetForegroundColor(Color::Yellow),\n                        Print(\"⚠ Installation cancelled by user\\n\"),\n                        ResetColor\n                    )?;\n                    return Err(Error::UserCancelled);\n                }\n                KeyCode::Up => {\n                    selected_index = selected_index.saturating_sub(1);\n                }\n                KeyCode::Down => {\n                    if selected_index < options.len() - 1 {\n                        selected_index += 1;\n                    }\n                }\n                KeyCode::Enter | KeyCode::Char(' ') => {\n                    break Ok(options[selected_index].1);\n                }\n                KeyCode::Char('1') => {\n                    break Ok(options[0].1);\n                }\n                KeyCode::Char('2') if options.len() > 1 => {\n                    break Ok(options[1].1);\n                }\n                KeyCode::Char('3') if options.len() > 2 => {\n                    break Ok(options[2].1);\n                }\n                KeyCode::Esc | KeyCode::Char('q') => {\n                    // Exit on escape/quit\n                    terminal::disable_raw_mode()?;\n                    execute!(\n                        io::stdout(),\n                        cursor::Show,\n                        cursor::MoveDown(options.len() as u16),\n                        Print(\"\\n\\n\"),\n                        SetForegroundColor(Color::Yellow),\n                        Print(\"⚠ Installation cancelled by user\\n\"),\n                        ResetColor\n                    )?;\n                    return Err(Error::UserCancelled);\n                }\n                _ => {}\n            }\n        }\n    };\n\n    // Clean up: disable raw mode and show cursor\n    terminal::disable_raw_mode()?;\n    execute!(io::stdout(), cursor::Show, cursor::MoveDown(options.len() as u16), Print(\"\\n\"))?;\n\n    // Print selection confirmation\n    if let Ok(pm) = &result {\n        let name = match pm {\n            PackageManagerType::Pnpm => \"pnpm\",\n            PackageManagerType::Npm => \"npm\",\n            PackageManagerType::Yarn => \"yarn\",\n        };\n        println!(\"\\n✓ Selected package manager: {name}\\n\");\n    }\n\n    result\n}\n\n/// Prompt the user to select a package manager\nfn prompt_package_manager_selection() -> Result<PackageManagerType, Error> {\n    // In CI environment, automatically use pnpm without prompting\n    if is_ci_environment() {\n        tracing::info!(\"CI environment detected. Using default package manager: pnpm\");\n        return Ok(PackageManagerType::Pnpm);\n    }\n\n    // Check if stdin is a TTY (terminal) - if not, use default\n    if !io::stdin().is_terminal() {\n        tracing::info!(\"Non-interactive environment detected. Using default package manager: pnpm\");\n        return Ok(PackageManagerType::Pnpm);\n    }\n\n    // Try interactive menu first, fall back to simple prompt on error\n    match interactive_package_manager_menu() {\n        Ok(pm) => Ok(pm),\n        Err(err) => {\n            match err {\n                Error::UserCancelled => Err(err),\n                // Fallback to simple text prompt if interactive menu fails\n                _ => simple_text_prompt(),\n            }\n        }\n    }\n}\n\n/// Simple text-based prompt as fallback\nfn simple_text_prompt() -> Result<PackageManagerType, Error> {\n    let managers = [\n        (\"pnpm\", PackageManagerType::Pnpm),\n        (\"npm\", PackageManagerType::Npm),\n        (\"yarn\", PackageManagerType::Yarn),\n    ];\n\n    println!(\"\\nNo package manager detected. Please select one:\");\n    println!(\"────────────────────────────────────────────────\");\n\n    for (i, (name, _)) in managers.iter().enumerate() {\n        if i == 0 {\n            println!(\"  [{}] {} (recommended)\", i + 1, name);\n        } else {\n            println!(\"  [{}] {}\", i + 1, name);\n        }\n    }\n\n    print!(\"\\nEnter your choice (1-{}) [default: 1]: \", managers.len());\n    io::stdout().flush()?;\n\n    let mut input = String::new();\n    io::stdin().read_line(&mut input)?;\n\n    let choice = input.trim();\n    let index = if choice.is_empty() {\n        0 // Default to pnpm\n    } else {\n        choice\n            .parse::<usize>()\n            .ok()\n            .and_then(|n| if n > 0 && n <= managers.len() { Some(n - 1) } else { None })\n            .unwrap_or(0) // Default to pnpm if invalid input\n    };\n\n    let (name, selected_type) = &managers[index];\n    println!(\"✓ Selected package manager: {name}\\n\");\n\n    Ok(*selected_type)\n}\n\n#[cfg(test)]\nmod tests {\n    use std::fs;\n\n    use tempfile::{TempDir, tempdir};\n\n    use super::*;\n\n    fn create_temp_dir() -> TempDir {\n        tempdir().expect(\"Failed to create temp directory\")\n    }\n\n    fn create_package_json(dir: &AbsolutePath, content: &str) {\n        fs::write(dir.join(\"package.json\"), content).expect(\"Failed to write package.json\");\n    }\n\n    fn create_pnpm_workspace_yaml(dir: &AbsolutePath, content: &str) {\n        fs::write(dir.join(\"pnpm-workspace.yaml\"), content)\n            .expect(\"Failed to write pnpm-workspace.yaml\");\n    }\n\n    #[test]\n    fn test_find_package_root() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let nested_dir = temp_dir_path.join(\"a\").join(\"b\").join(\"c\");\n        fs::create_dir_all(&nested_dir).unwrap();\n\n        // Create package.json in a/b\n        let package_dir = temp_dir_path.join(\"a\").join(\"b\");\n        File::create(package_dir.join(\"package.json\")).unwrap();\n\n        // Should find package.json in parent directory\n        let found = find_package_root(&nested_dir);\n        let package_root = found.unwrap();\n        assert_eq!(package_root.path, package_dir);\n\n        // Should return the same directory if package.json is there\n        let found = find_package_root(&package_dir);\n        let package_root = found.unwrap();\n        assert_eq!(package_root.path, package_dir);\n\n        // Should return PackageJsonNotFound error if no package.json found\n        let root_dir = temp_dir_path.join(\"x\").join(\"y\");\n        fs::create_dir_all(&root_dir).unwrap();\n        let found = find_package_root(&root_dir);\n        let err = found.unwrap_err();\n        assert!(matches!(err, vite_workspace::Error::PackageJsonNotFound(_)));\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_pnpm() {\n        let temp_dir = create_temp_dir();\n\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let nested_dir = temp_dir_path.join(\"packages\").join(\"app\");\n        fs::create_dir_all(&nested_dir).unwrap();\n\n        // Create pnpm-workspace.yaml at root\n        File::create(temp_dir_path.join(\"pnpm-workspace.yaml\")).unwrap();\n\n        // Should find workspace root\n        let (found, _) = find_workspace_root(&nested_dir).unwrap();\n        assert_eq!(&*found.path, &*temp_dir_path);\n        assert!(matches!(found.workspace_file, WorkspaceFile::PnpmWorkspaceYaml(_)));\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_npm_workspaces() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let nested_dir = temp_dir_path.join(\"packages\").join(\"app\");\n        fs::create_dir_all(&nested_dir).unwrap();\n\n        // Create package.json with workspaces field\n        let package_json = r#\"{\"workspaces\": [\"packages/*\"]}\"#;\n        fs::write(temp_dir_path.join(\"package.json\"), package_json).unwrap();\n\n        // Should find workspace root\n        let (found, _) = find_workspace_root(&temp_dir_path).unwrap();\n        assert_eq!(&*found.path, &*temp_dir_path);\n        assert!(matches!(found.workspace_file, WorkspaceFile::NpmWorkspaceJson(_)));\n    }\n\n    #[test]\n    fn test_find_workspace_root_fallback_to_package_root() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let nested_dir = temp_dir_path.join(\"src\");\n        fs::create_dir_all(&nested_dir).unwrap();\n\n        // Create package.json without workspaces field\n        let package_json = r#\"{\"name\": \"test\"}\"#;\n        fs::write(temp_dir_path.join(\"package.json\"), package_json).unwrap();\n\n        // Should fallback to package root\n        let (found, _) = find_workspace_root(&nested_dir).unwrap();\n        assert_eq!(&*found.path, &*temp_dir_path);\n        assert!(matches!(found.workspace_file, WorkspaceFile::NonWorkspacePackage(_)));\n        let package_root = find_package_root(&temp_dir_path).unwrap();\n        // equal to workspace root\n        assert_eq!(&*package_root.path, &*found.path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_package_json_not_found() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let nested_dir = temp_dir_path.join(\"src\");\n        fs::create_dir_all(&nested_dir).unwrap();\n\n        // Should return PackageJsonNotFound error if no package.json found\n        let found = find_workspace_root(&nested_dir);\n        let err = found.unwrap_err();\n        assert!(matches!(err, vite_workspace::Error::PackageJsonNotFound(_)));\n    }\n\n    #[test]\n    fn test_find_package_root_with_package_json_in_current_dir() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = find_package_root(&temp_dir_path).unwrap();\n        assert_eq!(result.path, temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_package_root_with_package_json_in_parent_dir() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let sub_dir = temp_dir_path.join(\"subdir\");\n        fs::create_dir(&sub_dir).expect(\"Failed to create subdirectory\");\n\n        let result = find_package_root(&sub_dir).unwrap();\n        assert_eq!(result.path, temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_package_root_with_package_json_in_grandparent_dir() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let sub_dir = temp_dir_path.join(\"subdir\").join(\"nested\");\n        fs::create_dir_all(&sub_dir).expect(\"Failed to create nested directories\");\n\n        let result = find_package_root(&sub_dir).unwrap();\n        assert_eq!(result.path, temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_pnpm_workspace_yaml() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let (result, _) = find_workspace_root(&temp_dir_path).expect(\"Should find workspace root\");\n        assert_eq!(&*result.path, &*temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_pnpm_workspace_yaml_in_parent_dir() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let sub_dir = temp_dir_path.join(\"subdir\");\n        fs::create_dir(&sub_dir).expect(\"Failed to create subdirectory\");\n\n        let (result, _) = find_workspace_root(&sub_dir).expect(\"Should find workspace root\");\n        assert_eq!(&*result.path, &*temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_package_json_workspaces() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-workspace\", \"workspaces\": [\"packages/*\"]}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (result, _) = find_workspace_root(&temp_dir_path).unwrap();\n        assert_eq!(&*result.path, &*temp_dir_path);\n        assert!(matches!(result.workspace_file, WorkspaceFile::NpmWorkspaceJson(_)));\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_package_json_workspaces_in_parent_dir() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-workspace\", \"workspaces\": [\"packages/*\"]}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let sub_dir = temp_dir_path.join(\"subdir\");\n        fs::create_dir(&sub_dir).expect(\"Failed to create subdirectory\");\n\n        let (result, _) = find_workspace_root(&sub_dir).unwrap();\n        assert_eq!(&*result.path, &*temp_dir_path);\n        assert!(matches!(result.workspace_file, WorkspaceFile::NpmWorkspaceJson(_)));\n    }\n\n    #[test]\n    fn test_find_workspace_root_prioritizes_pnpm_workspace_over_package_json() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with workspaces first\n        let package_content = r#\"{\"name\": \"test-workspace\", \"workspaces\": [\"packages/*\"]}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Then create pnpm-workspace.yaml (should take precedence)\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let (result, _) = find_workspace_root(&temp_dir_path).expect(\"Should find workspace root\");\n        assert_eq!(&*result.path, &*temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_falls_back_to_package_root_when_no_workspace_found() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let sub_dir = temp_dir_path.join(\"subdir\");\n        fs::create_dir(&sub_dir).expect(\"Failed to create subdirectory\");\n\n        let (result, _) = find_workspace_root(&sub_dir).expect(\"Should fall back to package root\");\n        assert_eq!(&*result.path, &*temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_nested_structure() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let nested_dir = temp_dir_path.join(\"packages\").join(\"app\").join(\"src\");\n        fs::create_dir_all(&nested_dir).expect(\"Failed to create nested directories\");\n\n        let (result, _) = find_workspace_root(&nested_dir).expect(\"Should find workspace root\");\n        assert_eq!(&*result.path, &*temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_without_workspace_files_returns_package_root() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (result, _) = find_workspace_root(&temp_dir_path).expect(\"Should return package root\");\n        assert_eq!(&*result.path, &*temp_dir_path);\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_invalid_package_json_handles_error() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let invalid_package_content = \"{ invalid json content\";\n        create_package_json(&temp_dir_path, invalid_package_content);\n\n        let result = find_workspace_root(&temp_dir_path);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_find_workspace_root_with_mixed_structure() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        // Create a package.json without workspaces\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create a subdirectory with its own package.json\n        let sub_dir = temp_dir_path.join(\"subdir\");\n        fs::create_dir(&sub_dir).expect(\"Failed to create subdirectory\");\n        let sub_package_content = r#\"{\"name\": \"sub-package\"}\"#;\n        create_package_json(&sub_dir, sub_package_content);\n\n        // Should find the subdirectory package.json since find_package_root searches upward from original_cwd\n        let (workspace_root, _) =\n            find_workspace_root(&sub_dir).expect(\"Should find subdirectory package\");\n        assert_eq!(&*workspace_root.path, &*sub_dir);\n        assert!(matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)));\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_pnpm_workspace_yaml() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let result =\n            PackageManager::builder(temp_dir_path).build().await.expect(\"Should detect pnpm\");\n        assert_eq!(result.bin_name, \"pnpm\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_pnpm_lock_yaml() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"version\": \"1.0.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create pnpm-lock.yaml\n        fs::write(temp_dir_path.join(\"pnpm-lock.yaml\"), \"lockfileVersion: '6.0'\")\n            .expect(\"Failed to write pnpm-lock.yaml\");\n\n        let result =\n            PackageManager::builder(temp_dir_path).build().await.expect(\"Should detect pnpm\");\n        assert_eq!(result.bin_name, \"pnpm\");\n\n        // check if the package.json file has the `packageManager` field\n        let package_json_path = temp_dir.path().join(\"package.json\");\n        let package_json: serde_json::Value =\n            serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap();\n        println!(\"package_json: {package_json:?}\");\n        assert!(package_json[\"packageManager\"].as_str().unwrap().starts_with(\"pnpm@\"));\n        // keep other fields\n        assert_eq!(package_json[\"version\"].as_str().unwrap(), \"1.0.0\");\n        assert_eq!(package_json[\"name\"].as_str().unwrap(), \"test-package\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_yarn_lock() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create yarn.lock\n        fs::write(temp_dir_path.join(\"yarn.lock\"), \"# yarn lockfile v1\")\n            .expect(\"Failed to write yarn.lock\");\n\n        let result = PackageManager::builder(temp_dir_path.to_absolute_path_buf())\n            .build()\n            .await\n            .expect(\"Should detect yarn\");\n        assert_eq!(result.bin_name, \"yarn\");\n        assert_eq!(result.workspace_root, temp_dir_path);\n        assert!(\n            result.get_bin_prefix().ends_with(\"yarn/bin\"),\n            \"bin_prefix should end with yarn/bin, but got {:?}\",\n            result.get_bin_prefix()\n        );\n        // package.json should have the `packageManager` field\n        let package_json_path = temp_dir_path.join(\"package.json\");\n        let package_json: serde_json::Value =\n            serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap();\n        println!(\"package_json: {package_json:?}\");\n        assert!(package_json[\"packageManager\"].as_str().unwrap().starts_with(\"yarn@\"));\n        // keep other fields\n        assert_eq!(package_json[\"name\"].as_str().unwrap(), \"test-package\");\n    }\n\n    #[tokio::test]\n    #[cfg(not(windows))] // FIXME\n    async fn test_detect_package_manager_with_package_lock_json() {\n        use std::process::Command;\n\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create package-lock.json\n        fs::write(temp_dir_path.join(\"package-lock.json\"), r#\"{\"lockfileVersion\": 2}\"#)\n            .expect(\"Failed to write package-lock.json\");\n\n        let result =\n            PackageManager::builder(temp_dir_path).build().await.expect(\"Should detect npm\");\n        assert_eq!(result.bin_name, \"npm\");\n\n        // check shim files\n        let bin_prefix = result.get_bin_prefix();\n        assert!(is_exists_file(bin_prefix.join(\"npm\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"npm.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"npm.ps1\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"npx\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"npx.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"npx.ps1\")).unwrap());\n\n        // run npm --version\n        let mut paths =\n            env::split_paths(&env::var_os(\"PATH\").unwrap_or_default()).collect::<Vec<_>>();\n        paths.insert(0, bin_prefix.into_path_buf());\n        let output = Command::new(\"npm\")\n            .arg(\"--version\")\n            .env(\"PATH\", env::join_paths(&paths).unwrap())\n            .output()\n            .expect(\"Failed to run npm\");\n        assert!(output.status.success(), \"stderr: {}\", String::from_utf8_lossy(&output.stderr));\n        // println!(\"npm --version: {:?}\", String::from_utf8_lossy(&output.stdout));\n\n        // run npx --version\n        let output = Command::new(\"npx\")\n            .arg(\"--version\")\n            .env(\"PATH\", env::join_paths(&paths).unwrap())\n            .output()\n            .expect(\"Failed to run npx\");\n        assert!(output.status.success(), \"stderr: {}\", String::from_utf8_lossy(&output.stderr));\n    }\n\n    #[tokio::test]\n    #[cfg(not(windows))] // FIXME\n    async fn test_detect_package_manager_with_package_manager_field() {\n        use std::process::Command;\n\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"pnpm@8.15.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect pnpm with version\");\n        assert_eq!(result.bin_name, \"pnpm\");\n\n        // check shim files\n        let bin_prefix = result.get_bin_prefix();\n        assert!(is_exists_file(bin_prefix.join(\"pnpm.cjs\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"pnpm.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"pnpm.ps1\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"pnpx.cjs\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"pnpx.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"pnpx.ps1\")).unwrap());\n\n        // run pnpm --version\n        let mut paths =\n            env::split_paths(&env::var_os(\"PATH\").unwrap_or_default()).collect::<Vec<_>>();\n        paths.insert(0, bin_prefix.into_path_buf());\n        let output = Command::new(\"pnpm\")\n            .arg(\"--version\")\n            .env(\"PATH\", env::join_paths(paths).unwrap())\n            .output()\n            .expect(\"Failed to run pnpm\");\n        // println!(\"pnpm --version: {:?}\", output);\n        assert!(output.status.success());\n        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), \"8.15.0\");\n    }\n\n    #[tokio::test]\n    async fn test_parse_package_manager_with_hash() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Test with sha512 hash\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap();\n        let (pm_type, version, hash) =\n            get_package_manager_type_and_version(&workspace_root, None).unwrap();\n\n        assert_eq!(pm_type, PackageManagerType::Yarn);\n        assert_eq!(version, \"1.22.22\");\n        assert!(hash.is_some());\n        assert_eq!(\n            hash.unwrap(),\n            \"sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_parse_package_manager_with_sha1_hash() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Test with sha1 hash\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"npm@10.5.0+sha1.abcd1234567890abcdef1234567890abcdef1234\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap();\n        let (pm_type, version, hash) =\n            get_package_manager_type_and_version(&workspace_root, None).unwrap();\n\n        assert_eq!(pm_type, PackageManagerType::Npm);\n        assert_eq!(version, \"10.5.0\");\n        assert!(hash.is_some());\n        assert_eq!(hash.unwrap(), \"sha1.abcd1234567890abcdef1234567890abcdef1234\");\n    }\n\n    #[tokio::test]\n    async fn test_parse_package_manager_with_sha224_hash() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Test with sha224 hash\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"pnpm@8.15.0+sha224.1234567890abcdef1234567890abcdef1234567890abcdef12345678\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap();\n        let (pm_type, version, hash) =\n            get_package_manager_type_and_version(&workspace_root, None).unwrap();\n\n        assert_eq!(pm_type, PackageManagerType::Pnpm);\n        assert_eq!(version, \"8.15.0\");\n        assert!(hash.is_some());\n        assert_eq!(\n            hash.unwrap(),\n            \"sha224.1234567890abcdef1234567890abcdef1234567890abcdef12345678\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_parse_package_manager_with_sha256_hash() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Test with sha256 hash\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@4.0.0+sha256.1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap();\n        let (pm_type, version, hash) =\n            get_package_manager_type_and_version(&workspace_root, None).unwrap();\n\n        assert_eq!(pm_type, PackageManagerType::Yarn);\n        assert_eq!(version, \"4.0.0\");\n        assert!(hash.is_some());\n        assert_eq!(\n            hash.unwrap(),\n            \"sha256.1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_parse_package_manager_without_hash() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Test without hash\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"pnpm@8.15.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap();\n        let (pm_type, version, hash) =\n            get_package_manager_type_and_version(&workspace_root, None).unwrap();\n\n        assert_eq!(pm_type, PackageManagerType::Pnpm);\n        assert_eq!(version, \"8.15.0\");\n        assert!(hash.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_download_success_package_manager_with_hash() {\n        use std::process::Command;\n\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect yarn with version and hash\");\n        assert_eq!(result.bin_name, \"yarn\");\n\n        // check shim files\n        let bin_prefix = result.get_bin_prefix();\n        assert!(is_exists_file(bin_prefix.join(\"yarn.js\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarn\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarn.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarn.ps1\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarnpkg\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarnpkg.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarnpkg.ps1\")).unwrap());\n\n        // run pnpm --version\n        let mut paths =\n            env::split_paths(&env::var_os(\"PATH\").unwrap_or_default()).collect::<Vec<_>>();\n        paths.insert(0, bin_prefix.into_path_buf());\n        let mut cmd = \"yarn\";\n        if cfg!(windows) {\n            cmd = \"yarn.cmd\";\n        }\n        let output = Command::new(cmd)\n            .arg(\"--version\")\n            .env(\"PATH\", env::join_paths(paths).unwrap())\n            .output()\n            .expect(\"Failed to run yarn\");\n        // println!(\"pnpm --version: {:?}\", output);\n        assert!(output.status.success());\n        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), \"1.22.22\");\n    }\n\n    #[tokio::test]\n    async fn test_download_failed_package_manager_with_hash() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@1.22.21+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path).build().await;\n        assert!(result.is_err());\n        // Check if it's the expected error type\n        if let Err(Error::HashMismatch { expected, actual }) = result {\n            assert_eq!(\n                expected,\n                \"sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e\"\n            );\n            assert_eq!(\n                actual,\n                \"sha512.ca75da26c00327d26267ce33536e5790f18ebd53266796fbb664d2a4a5116308042dd8ee7003b276a20eace7d3c5561c3577bdd71bcb67071187af124779620a\"\n            );\n        } else {\n            panic!(\"Expected HashMismatch error\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_download_success_package_manager_with_sha1_and_sha224() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@1.22.20+sha1.167c8ab8d9c8c3826d3725d9579aaea8b47a2b18\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect yarn with version and hash\");\n        assert_eq!(result.bin_name, \"yarn\");\n\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"pnpm@4.11.6+sha224.7783c4b01916b7a69e6ff05d328df6f83cb7f127e9c96be88739386d\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect pnpm with version and hash\");\n        assert_eq!(result.bin_name, \"pnpm\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_yarn_package_manager_field() {\n        use std::process::Command;\n\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@4.0.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path.clone())\n            .build()\n            .await\n            .expect(\"Should detect yarn with version\");\n        assert_eq!(result.bin_name, \"yarn\");\n\n        assert_eq!(result.version, \"4.0.0\");\n        assert_eq!(result.workspace_root, temp_dir_path);\n        assert!(\n            result.get_bin_prefix().ends_with(\"yarn/bin\"),\n            \"bin_prefix should end with yarn/bin, but got {:?}\",\n            result.get_bin_prefix()\n        );\n\n        // check shim files\n        let bin_prefix = result.get_bin_prefix();\n        assert!(is_exists_file(bin_prefix.join(\"yarn.js\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarn\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarn.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarn.ps1\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarnpkg\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarnpkg.cmd\")).unwrap());\n        assert!(is_exists_file(bin_prefix.join(\"yarnpkg.ps1\")).unwrap());\n\n        // run yarn --version\n        let mut cmd = \"yarn\";\n        if cfg!(windows) {\n            cmd = \"yarn.cmd\";\n        }\n        let mut paths =\n            env::split_paths(&env::var_os(\"PATH\").unwrap_or_default()).collect::<Vec<_>>();\n        paths.insert(0, bin_prefix.into_path_buf());\n        let output = Command::new(cmd)\n            .arg(\"--version\")\n            .env(\"PATH\", env::join_paths(paths).unwrap())\n            .output()\n            .expect(\"Failed to run yarn\");\n        assert!(output.status.success());\n        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), \"4.0.0\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_npm_package_manager_field() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"npm@10.0.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect npm with version\");\n        assert_eq!(result.bin_name, \"npm\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_invalid_package_manager_field() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"invalid@1.0.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path).build().await;\n        assert!(result.is_err());\n        // Check if it's the expected error type\n        if let Err(Error::UnsupportedPackageManager(name)) = result {\n            assert_eq!(name, \"invalid\");\n        } else {\n            panic!(\"Expected UnsupportedPackageManager error\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_not_exists_version_in_package_manager_field() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content =\n            r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@10000000000.0.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path).build().await;\n        assert!(result.is_err());\n        println!(\"result: {result:?}\");\n        // Check if it's the expected error type\n        if let Err(Error::PackageManagerVersionNotFound { name, version, .. }) = result {\n            assert_eq!(name, \"yarn\");\n            assert_eq!(version, \"10000000000.0.0\");\n        } else {\n            panic!(\"Expected PackageManagerVersionNotFound error, got {result:?}\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_invalid_semver() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content =\n            r#\"{\"name\": \"test-package\", \"packageManager\": \"pnpm@invalid-version\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path).build().await;\n        println!(\"result: {result:?}\");\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_default_fallback() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path.clone())\n            .package_manager_type(PackageManagerType::Yarn)\n            .build()\n            .await\n            .expect(\"Should use default\");\n        assert_eq!(result.bin_name, \"yarn\");\n        // package.json should have the `packageManager` field\n        let package_json_path = temp_dir_path.join(\"package.json\");\n        let package_json: serde_json::Value =\n            serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap();\n        // println!(\"package_json: {:?}\", package_json);\n        assert!(package_json[\"packageManager\"].as_str().unwrap().starts_with(\"yarn@\"));\n        // keep other fields\n        assert_eq!(package_json[\"name\"].as_str().unwrap(), \"test-package\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_without_any_indicators() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        let result = PackageManager::builder(temp_dir_path).build().await;\n        assert!(result.is_err());\n        // Check if it's the expected error type\n        if matches!(result, Err(Error::UnrecognizedPackageManager)) {\n            // Expected error\n        } else {\n            panic!(\"Expected UnrecognizedPackageManager error\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_prioritizes_package_manager_field_over_lock_files() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\", \"packageManager\": \"yarn@4.0.0\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create pnpm-lock.yaml (should be ignored due to packageManager field)\n        fs::write(temp_dir_path.join(\"pnpm-lock.yaml\"), \"lockfileVersion: '6.0'\")\n            .expect(\"Failed to write pnpm-lock.yaml\");\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect yarn from packageManager field\");\n        assert_eq!(result.bin_name, \"yarn\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_prioritizes_pnpm_workspace_over_lock_files() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create yarn.lock (should be ignored due to pnpm-workspace.yaml)\n        fs::write(temp_dir_path.join(\"yarn.lock\"), \"# yarn lockfile v1\")\n            .expect(\"Failed to write yarn.lock\");\n\n        // Create pnpm-workspace.yaml (should take precedence)\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect pnpm from workspace file\");\n        assert_eq!(result.bin_name, \"pnpm\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_from_subdirectory() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let workspace_content = \"packages:\\n  - 'packages/*'\";\n        create_pnpm_workspace_yaml(&temp_dir_path, workspace_content);\n\n        let sub_dir = temp_dir_path.join(\"packages\").join(\"app\");\n        fs::create_dir_all(&sub_dir).expect(\"Failed to create subdirectory\");\n\n        let result = PackageManager::builder(sub_dir)\n            .build()\n            .await\n            .expect(\"Should detect pnpm from parent workspace\");\n        assert_eq!(result.bin_name, \"pnpm\");\n        assert!(result.get_bin_prefix().ends_with(\"pnpm/bin\"));\n    }\n\n    #[tokio::test]\n    async fn test_download_package_manager() {\n        let result = download_package_manager(PackageManagerType::Yarn, \"4.9.2\", None).await;\n        assert!(result.is_ok());\n        let (target_dir, package_name, version) = result.unwrap();\n        println!(\"result: {target_dir:?}\");\n        assert!(is_exists_file(target_dir.join(\"bin/yarn\")).unwrap());\n        assert!(is_exists_file(target_dir.join(\"bin/yarn.cmd\")).unwrap());\n        assert_eq!(package_name, \"@yarnpkg/cli-dist\");\n        assert_eq!(version, \"4.9.2\");\n\n        // again should skip download\n        let result = download_package_manager(PackageManagerType::Yarn, \"4.9.2\", None).await;\n        assert!(result.is_ok());\n        let (target_dir, package_name, version) = result.unwrap();\n        assert!(is_exists_file(target_dir.join(\"bin/yarn\")).unwrap());\n        assert!(is_exists_file(target_dir.join(\"bin/yarn.cmd\")).unwrap());\n        assert_eq!(package_name, \"@yarnpkg/cli-dist\");\n        assert_eq!(version, \"4.9.2\");\n        remove_dir_all_force(target_dir).await.unwrap();\n    }\n\n    #[tokio::test]\n    async fn test_get_latest_version() {\n        let result = get_latest_version(PackageManagerType::Yarn).await;\n        assert!(result.is_ok());\n        let version = result.unwrap();\n        // println!(\"version: {:?}\", version);\n        assert!(!version.is_empty());\n        // check version should >= 4.0.0\n        let version_req = VersionReq::parse(\">=4.0.0\");\n        assert!(version_req.is_ok());\n        let version_req = version_req.unwrap();\n        assert!(version_req.matches(&Version::parse(&version).unwrap()));\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_yarnrc_yml() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create .yarnrc.yml\n        fs::write(\n            temp_dir_path.join(\".yarnrc.yml\"),\n            \"nodeLinker: node-modules\\nyarnPath: .yarn/releases/yarn-4.0.0.cjs\",\n        )\n        .expect(\"Failed to write .yarnrc.yml\");\n\n        let result = PackageManager::builder(temp_dir_path.clone())\n            .build()\n            .await\n            .expect(\"Should detect yarn from .yarnrc.yml\");\n        assert_eq!(result.bin_name, \"yarn\");\n        assert_eq!(result.workspace_root, temp_dir_path);\n        assert!(\n            result.get_bin_prefix().ends_with(\"yarn/bin\"),\n            \"bin_prefix should end with yarn/bin, but got {:?}\",\n            result.get_bin_prefix()\n        );\n        // package.json should have the `packageManager` field\n        let package_json_path = temp_dir.path().join(\"package.json\");\n        let package_json: serde_json::Value =\n            serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap();\n        assert!(package_json[\"packageManager\"].as_str().unwrap().starts_with(\"yarn@\"));\n        // keep other fields\n        assert_eq!(package_json[\"name\"].as_str().unwrap(), \"test-package\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_pnpmfile_cjs() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create pnpmfile.cjs\n        fs::write(temp_dir_path.join(\"pnpmfile.cjs\"), \"module.exports = { hooks: {} }\")\n            .expect(\"Failed to write pnpmfile.cjs\");\n\n        let result = PackageManager::builder(temp_dir_path.clone())\n            .build()\n            .await\n            .expect(\"Should detect pnpm from pnpmfile.cjs\");\n        assert_eq!(result.bin_name, \"pnpm\");\n        assert_eq!(result.workspace_root, temp_dir_path);\n        assert!(\n            result.get_bin_prefix().ends_with(\"pnpm/bin\"),\n            \"bin_prefix should end with pnpm/bin, but got {:?}\",\n            result.get_bin_prefix()\n        );\n        // package.json should have the `packageManager` field\n        let package_json_path = temp_dir_path.join(\"package.json\");\n        let package_json: serde_json::Value =\n            serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap();\n        assert!(package_json[\"packageManager\"].as_str().unwrap().starts_with(\"pnpm@\"));\n        // keep other fields\n        assert_eq!(package_json[\"name\"].as_str().unwrap(), \"test-package\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_with_yarn_config_cjs() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create yarn.config.cjs\n        fs::write(\n            temp_dir_path.join(\"yarn.config.cjs\"),\n            \"module.exports = { nodeLinker: 'node-modules' }\",\n        )\n        .expect(\"Failed to write yarn.config.cjs\");\n\n        let result = PackageManager::builder(temp_dir_path.clone())\n            .build()\n            .await\n            .expect(\"Should detect yarn from yarn.config.cjs\");\n        assert_eq!(result.bin_name, \"yarn\");\n        assert_eq!(result.workspace_root, temp_dir_path);\n        assert!(\n            result.get_bin_prefix().ends_with(\"yarn/bin\"),\n            \"bin_prefix should end with yarn/bin, but got {:?}\",\n            result.get_bin_prefix()\n        );\n        // package.json should have the `packageManager` field\n        let package_json_path = temp_dir_path.join(\"package.json\");\n        let package_json: serde_json::Value =\n            serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap();\n        assert!(package_json[\"packageManager\"].as_str().unwrap().starts_with(\"yarn@\"));\n        // keep other fields\n        assert_eq!(package_json[\"name\"].as_str().unwrap(), \"test-package\");\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_priority_order_lock_over_config() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create multiple detection files to test priority order\n        // According to vite-install.md, pnpmfile.cjs and yarn.config.cjs are lower priority than lock files\n\n        // Create pnpmfile.cjs\n        fs::write(temp_dir_path.join(\"pnpmfile.cjs\"), \"module.exports = { hooks: {} }\")\n            .expect(\"Failed to write pnpmfile.cjs\");\n\n        // Create yarn.config.cjs\n        fs::write(\n            temp_dir_path.join(\"yarn.config.cjs\"),\n            \"module.exports = { nodeLinker: 'node-modules' }\",\n        )\n        .expect(\"Failed to write yarn.config.cjs\");\n\n        // Create package-lock.json (should take precedence over pnpmfile.cjs and yarn.config.cjs)\n        fs::write(temp_dir_path.join(\"package-lock.json\"), r#\"{\"lockfileVersion\": 3}\"#)\n            .expect(\"Failed to write package-lock.json\");\n\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect npm from package-lock.json\");\n        assert_eq!(\n            result.bin_name, \"npm\",\n            \"package-lock.json should take precedence over pnpmfile.cjs and yarn.config.cjs\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_detect_package_manager_pnpmfile_over_yarn_config() {\n        let temp_dir = create_temp_dir();\n        let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let package_content = r#\"{\"name\": \"test-package\"}\"#;\n        create_package_json(&temp_dir_path, package_content);\n\n        // Create both pnpmfile.cjs and yarn.config.cjs\n        fs::write(temp_dir_path.join(\"pnpmfile.cjs\"), \"module.exports = { hooks: {} }\")\n            .expect(\"Failed to write pnpmfile.cjs\");\n\n        fs::write(\n            temp_dir_path.join(\"yarn.config.cjs\"),\n            \"module.exports = { nodeLinker: 'node-modules' }\",\n        )\n        .expect(\"Failed to write yarn.config.cjs\");\n\n        // pnpmfile.cjs should be detected first (before yarn.config.cjs)\n        let result = PackageManager::builder(temp_dir_path)\n            .build()\n            .await\n            .expect(\"Should detect pnpm from pnpmfile.cjs\");\n        assert_eq!(\n            result.bin_name, \"pnpm\",\n            \"pnpmfile.cjs should be detected before yarn.config.cjs\"\n        );\n    }\n    // Tests for get_fingerprint_ignores method\n    mod get_fingerprint_ignores_tests {\n        use vite_glob::GlobPatternSet;\n\n        use super::*;\n\n        fn create_mock_package_manager(\n            temp_dir: TempDir,\n            pm_type: PackageManagerType,\n            is_monorepo: bool,\n        ) -> PackageManager {\n            let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n            let install_dir = temp_dir_path.join(\"install\");\n\n            PackageManager {\n                client: pm_type,\n                package_name: pm_type.to_string().into(),\n                version: \"1.0.0\".into(),\n                hash: None,\n                bin_name: pm_type.to_string().into(),\n                workspace_root: temp_dir_path,\n                is_monorepo,\n                install_dir,\n            }\n        }\n\n        #[test]\n        fn test_get_fingerprint_ignores_monorepo() {\n            let temp_dir: TempDir = create_temp_dir();\n            let pm = create_mock_package_manager(temp_dir, PackageManagerType::Pnpm, true);\n            // mkdir packages/app\n            fs::create_dir_all(pm.workspace_root.join(\"packages/app\"))\n                .expect(\"Failed to create packages/app directory\");\n            // create pnpm-workspace.yaml\n            fs::write(\n                pm.workspace_root.join(\"pnpm-workspace.yaml\"),\n                \"packages:\n  - 'packages/*'\n\",\n            )\n            .expect(\"Failed to write pnpm-workspace.yaml\");\n            // create package.json\n            fs::write(pm.workspace_root.join(\"package.json\"), \"{\\\"name\\\": \\\"test-package\\\"}\")\n                .expect(\"Failed to write package.json\");\n            // create packages/app/package.json\n            fs::write(\n                pm.workspace_root.join(\"packages/app/package.json\"),\n                \"{\\\"name\\\": \\\"test-package-app\\\"}\",\n            )\n            .expect(\"Failed to write packages/app/package.json\");\n            let ignores = pm.get_fingerprint_ignores().expect(\"Should get fingerprint ignores\");\n            let matcher = GlobPatternSet::new(&ignores).expect(\"Should compile patterns\");\n            assert!(!matcher.is_match(\"packages\"), \"Should not ignore packages directory\");\n            assert!(matcher.is_match(\"packages/app\"), \"Should ignore packages/app directory\");\n            assert_eq!(\n                ignores,\n                [\n                    \"**/*\",\n                    \"!**/package.json\",\n                    \"!**/.npmrc\",\n                    \"!**/pnpm-workspace.yaml\",\n                    \"!**/pnpm-lock.yaml\",\n                    \"!**/.pnpmfile.cjs\",\n                    \"!**/pnpmfile.cjs\",\n                    \"!**/.pnp.cjs\",\n                    \"!packages\",\n                    \"**/node_modules/**/*\",\n                    \"!**/node_modules\",\n                    \"!**/node_modules/@*\",\n                    \"**/node_modules/**/node_modules/**\"\n                ]\n            );\n        }\n\n        #[test]\n        fn test_pnpm_fingerprint_ignores() {\n            let temp_dir: TempDir = create_temp_dir();\n            let pm = create_mock_package_manager(temp_dir, PackageManagerType::Pnpm, false);\n            let ignores = pm.get_fingerprint_ignores().expect(\"Should get fingerprint ignores\");\n            let matcher = GlobPatternSet::new(&ignores).expect(\"Should compile patterns\");\n\n            // Should ignore most files in node_modules\n            assert!(\n                matcher.is_match(\"node_modules/pkg-a/index.js\"),\n                \"Should ignore implementation files\"\n            );\n            assert!(\n                matcher.is_match(\"foo/bar/node_modules/pkg-a/lib/util.js\"),\n                \"Should ignore nested files\"\n            );\n            assert!(matcher.is_match(\"node_modules/.bin/cli\"), \"Should ignore binaries\");\n\n            // Should NOT ignore package.json files (including in node_modules)\n            assert!(!matcher.is_match(\"package.json\"), \"Should NOT ignore root package.json\");\n            assert!(\n                !matcher.is_match(\"packages/app/package.json\"),\n                \"Should NOT ignore package package.json\"\n            );\n\n            // Should ignore package.json files under node_modules\n            assert!(\n                matcher.is_match(\"node_modules/pkg-a/package.json\"),\n                \"Should ignore package.json in node_modules\"\n            );\n            assert!(\n                matcher.is_match(\"foo/bar/node_modules/pkg-a/package.json\"),\n                \"Should ignore package.json in node_modules\"\n            );\n            assert!(\n                matcher.is_match(\"node_modules/@scope/pkg-a/package.json\"),\n                \"Should ignore package.json in node_modules\"\n            );\n\n            // Should keep node_modules directories themselves\n            assert!(!matcher.is_match(\"node_modules\"), \"Should NOT ignore node_modules directory\");\n            assert!(\n                !matcher.is_match(\"packages/app/node_modules\"),\n                \"Should NOT ignore nested node_modules\"\n            );\n            assert!(\n                matcher.is_match(\"node_modules/mqtt/node_modules\"),\n                \"Should ignore sub node_modules under node_modules\"\n            );\n            assert!(\n                matcher\n                    .is_match(\"node_modules/minimatch/node_modules/brace-expansion/node_modules\"),\n                \"Should ignore sub node_modules under node_modules\"\n            );\n            assert!(\n                matcher.is_match(\"packages/app/node_modules/@octokit/graphql/node_modules\"),\n                \"Should ignore sub node_modules under node_modules\"\n            );\n\n            // Should keep the root scoped directory under node_modules\n            assert!(!matcher.is_match(\"node_modules/@types\"), \"Should NOT ignore scoped directory\");\n            assert!(\n                matcher.is_match(\"node_modules/@types/node\"),\n                \"Should ignore scoped sub directory\"\n            );\n\n            // Pnpm-specific files should NOT be ignored\n            assert!(\n                !matcher.is_match(\"pnpm-workspace.yaml\"),\n                \"Should NOT ignore pnpm-workspace.yaml\"\n            );\n            assert!(!matcher.is_match(\"pnpm-lock.yaml\"), \"Should NOT ignore pnpm-lock.yaml\");\n            assert!(!matcher.is_match(\".pnpmfile.cjs\"), \"Should NOT ignore .pnpmfile.cjs\");\n            assert!(!matcher.is_match(\"pnpmfile.cjs\"), \"Should NOT ignore pnpmfile.cjs\");\n            assert!(!matcher.is_match(\".pnp.cjs\"), \"Should NOT ignore .pnp.cjs\");\n            assert!(!matcher.is_match(\".npmrc\"), \"Should NOT ignore .npmrc\");\n\n            // Other package manager files should be ignored\n            assert!(matcher.is_match(\"yarn.lock\"), \"Should ignore yarn.lock\");\n            assert!(matcher.is_match(\"package-lock.json\"), \"Should ignore package-lock.json\");\n\n            // Regular source files should be ignored\n            assert!(matcher.is_match(\"src/index.js\"), \"Should ignore source files\");\n            assert!(matcher.is_match(\"dist/bundle.js\"), \"Should ignore build outputs\");\n        }\n\n        #[test]\n        fn test_yarn_fingerprint_ignores() {\n            let temp_dir: TempDir = create_temp_dir();\n            let pm = create_mock_package_manager(temp_dir, PackageManagerType::Yarn, false);\n            let ignores = pm.get_fingerprint_ignores().expect(\"Should get fingerprint ignores\");\n            let matcher = GlobPatternSet::new(&ignores).expect(\"Should compile patterns\");\n\n            // Should ignore most files in node_modules\n            assert!(\n                matcher.is_match(\"node_modules/react/index.js\"),\n                \"Should ignore implementation files\"\n            );\n            assert!(\n                matcher.is_match(\"node_modules/react/cjs/react.production.js\"),\n                \"Should ignore nested files\"\n            );\n\n            // Should NOT ignore package.json files (including in node_modules)\n            assert!(!matcher.is_match(\"package.json\"), \"Should NOT ignore root package.json\");\n            assert!(\n                !matcher.is_match(\"apps/web/package.json\"),\n                \"Should NOT ignore app package.json\"\n            );\n\n            // Should ignore package.json files under node_modules\n            assert!(\n                matcher.is_match(\"node_modules/react/package.json\"),\n                \"Should ignore package.json in node_modules\"\n            );\n\n            // Should keep node_modules directories\n            assert!(!matcher.is_match(\"node_modules\"), \"Should NOT ignore node_modules directory\");\n            assert!(!matcher.is_match(\"node_modules/@types\"), \"Should NOT ignore scoped packages\");\n\n            // Yarn-specific files should NOT be ignored\n            assert!(!matcher.is_match(\".yarnrc\"), \"Should NOT ignore .yarnrc\");\n            assert!(!matcher.is_match(\".yarnrc.yml\"), \"Should NOT ignore .yarnrc.yml\");\n            assert!(!matcher.is_match(\"yarn.config.cjs\"), \"Should NOT ignore yarn.config.cjs\");\n            assert!(!matcher.is_match(\"yarn.lock\"), \"Should NOT ignore yarn.lock\");\n            assert!(\n                !matcher.is_match(\".yarn/releases/yarn-4.0.0.cjs\"),\n                \"Should NOT ignore .yarn contents\"\n            );\n            assert!(\n                !matcher.is_match(\".yarn/patches/package.patch\"),\n                \"Should NOT ignore .yarn patches\"\n            );\n            assert!(\n                !matcher.is_match(\".yarn/patches/yjs-npm-13.6.21-c9f1f3397c.patch\"),\n                \"Should NOT ignore .yarn patches\"\n            );\n            assert!(!matcher.is_match(\".pnp.cjs\"), \"Should NOT ignore .pnp.cjs\");\n            assert!(!matcher.is_match(\".npmrc\"), \"Should NOT ignore .npmrc\");\n\n            // Other package manager files should be ignored\n            assert!(matcher.is_match(\"pnpm-lock.yaml\"), \"Should ignore pnpm-lock.yaml\");\n            assert!(matcher.is_match(\"package-lock.json\"), \"Should ignore package-lock.json\");\n\n            // Regular source files should be ignored\n            assert!(matcher.is_match(\"src/components/Button.tsx\"), \"Should ignore source files\");\n\n            // Should ignore nested node_modules\n            assert!(\n                matcher.is_match(\n                    \"node_modules/@mixmark-io/domino/.yarn/plugins/@yarnpkg/plugin-version.cjs\"\n                ),\n                \"Should ignore sub node_modules under node_modules\"\n            );\n            assert!(\n                matcher.is_match(\"node_modules/touch/node_modules\"),\n                \"Should ignore sub node_modules under node_modules\"\n            );\n        }\n\n        #[test]\n        fn test_npm_fingerprint_ignores() {\n            let temp_dir: TempDir = create_temp_dir();\n            let pm = create_mock_package_manager(temp_dir, PackageManagerType::Npm, false);\n            let ignores = pm.get_fingerprint_ignores().expect(\"Should get fingerprint ignores\");\n            let matcher = GlobPatternSet::new(&ignores).expect(\"Should compile patterns\");\n\n            // Should ignore most files in node_modules\n            assert!(\n                matcher.is_match(\"node_modules/express/index.js\"),\n                \"Should ignore implementation files\"\n            );\n            assert!(\n                matcher.is_match(\"node_modules/express/lib/application.js\"),\n                \"Should ignore nested files\"\n            );\n\n            // Should NOT ignore package.json files (including in node_modules)\n            assert!(!matcher.is_match(\"package.json\"), \"Should NOT ignore root package.json\");\n            assert!(!matcher.is_match(\"src/package.json\"), \"Should NOT ignore nested package.json\");\n\n            // Should ignore package.json files under node_modules\n            assert!(\n                matcher.is_match(\"node_modules/express/package.json\"),\n                \"Should ignore package.json in node_modules\"\n            );\n\n            // Should keep node_modules directories\n            assert!(!matcher.is_match(\"node_modules\"), \"Should NOT ignore node_modules directory\");\n            assert!(!matcher.is_match(\"node_modules/@babel\"), \"Should NOT ignore scoped packages\");\n\n            // Npm-specific files should NOT be ignored\n            assert!(!matcher.is_match(\"package-lock.json\"), \"Should NOT ignore package-lock.json\");\n            assert!(\n                !matcher.is_match(\"npm-shrinkwrap.json\"),\n                \"Should NOT ignore npm-shrinkwrap.json\"\n            );\n            assert!(!matcher.is_match(\".npmrc\"), \"Should NOT ignore .npmrc\");\n\n            // Other package manager files should be ignored\n            assert!(matcher.is_match(\"pnpm-lock.yaml\"), \"Should ignore pnpm-lock.yaml\");\n            assert!(matcher.is_match(\"yarn.lock\"), \"Should ignore yarn.lock\");\n\n            // Regular files should be ignored\n            assert!(matcher.is_match(\"README.md\"), \"Should ignore docs\");\n            assert!(matcher.is_match(\"src/app.ts\"), \"Should ignore source files\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/request.rs",
    "content": "use std::{path::Path, time::Duration};\n\nuse backon::{ExponentialBuilder, Retryable};\nuse flate2::read::GzDecoder;\nuse futures_util::stream::StreamExt;\nuse reqwest::Response;\nuse serde::de::DeserializeOwned;\nuse sha1::Sha1;\nuse sha2::{Digest, Sha224, Sha256, Sha512};\nuse tar::Archive;\nuse tokio::{fs, io::AsyncWriteExt};\nuse vite_error::Error;\n\n/// HTTP client with built-in retry support\n#[derive(Clone)]\npub struct HttpClient {\n    max_times: usize,\n    min_delay: u64,\n}\n\nimpl Default for HttpClient {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl HttpClient {\n    /// Create a new HTTP client with default settings (3 retries, 500ms min delay)\n    pub const fn new() -> Self {\n        Self::with_config(3, 500)\n    }\n\n    /// Create a new HTTP client with custom retry configuration\n    ///\n    /// # Arguments\n    ///\n    /// * `max_times` - Maximum number of retry attempts\n    /// * `min_delay` - Minimum delay in milliseconds for exponential backoff\n    pub const fn with_config(max_times: usize, min_delay: u64) -> Self {\n        Self { max_times, min_delay }\n    }\n\n    /// Get raw bytes from a URL\n    ///\n    /// # Arguments\n    ///\n    /// * `url` - The URL to fetch bytes from\n    ///\n    /// # Returns\n    ///\n    /// * `Ok(Vec<u8>)` - The raw bytes from the response\n    /// * `Err(e)` - If the request fails\n    pub async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, Error> {\n        tracing::debug!(\"Fetching bytes from: {}\", url);\n        let response = self.get(url).await?;\n        Ok(response.bytes().await?.to_vec())\n    }\n\n    async fn get(&self, url: &str) -> Result<Response, Error> {\n        let response = (|| async { reqwest::get(url).await?.error_for_status() })\n            .retry(\n                ExponentialBuilder::default()\n                    .with_jitter()\n                    .with_min_delay(Duration::from_millis(self.min_delay))\n                    .with_max_times(self.max_times),\n            )\n            .await?;\n\n        Ok(response)\n    }\n\n    /// Get JSON data from a URL\n    ///\n    /// # Arguments\n    ///\n    /// * `url` - The URL to fetch JSON from\n    ///\n    /// # Returns\n    ///\n    /// * `Ok(T)` - Deserialized JSON data\n    /// * `Err(e)` - If the request fails or JSON deserialization fails\n    pub async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, Error> {\n        tracing::debug!(\"Fetching JSON from: {}\", url);\n\n        let response = self.get(url).await?;\n        let data = response.json::<T>().await?;\n        Ok(data)\n    }\n\n    /// Download a file to a specified path\n    ///\n    /// # Arguments\n    ///\n    /// * `url` - The URL of the file to download\n    /// * `target_path` - The path where the file will be saved\n    ///\n    /// # Returns\n    ///\n    /// * `Ok(())` - If the file is downloaded successfully\n    /// * `Err(e)` - If the download fails\n    pub async fn download_file(\n        &self,\n        url: &str,\n        target_path: impl AsRef<Path>,\n    ) -> Result<(), Error> {\n        let target_path = target_path.as_ref();\n        tracing::debug!(\"Downloading {} to {:?}\", url, target_path);\n\n        let response = self.get(url).await?;\n\n        self.write_response_to_file(response, target_path).await?;\n\n        tracing::debug!(\"Download completed: {:?}\", target_path);\n        Ok(())\n    }\n\n    /// Internal helper to write response body to file\n    async fn write_response_to_file(\n        &self,\n        response: Response,\n        target_path: &Path,\n    ) -> Result<(), Error> {\n        // Create the target file\n        let mut file = fs::File::create(target_path).await?;\n\n        // Stream the response body to the file\n        let mut stream = response.bytes_stream();\n        while let Some(chunk_result) = stream.next().await {\n            let chunk = chunk_result?;\n            file.write_all(&chunk).await?;\n        }\n\n        file.flush().await?;\n        Ok(())\n    }\n}\n\nfn extract_tgz(tgz_file: impl AsRef<Path>, target_dir: impl AsRef<Path>) -> Result<(), Error> {\n    let tgz_file = tgz_file.as_ref();\n    let target_dir = target_dir.as_ref();\n    tracing::debug!(\"Extract tgz: {:?} to {:?}\", tgz_file, target_dir);\n\n    let file = std::fs::File::open(tgz_file)?;\n    let tar_stream = GzDecoder::new(file);\n    let mut archive = Archive::new(tar_stream);\n    archive.unpack(target_dir)?;\n\n    tracing::debug!(\"Extract tgz finished\");\n\n    Ok(())\n}\n\n/// Download a tgz file from a URL and extract it to a target directory with optional hash verification.\n///\n/// # Arguments\n/// * `url` - The URL of the tgz file to download.\n/// * `target_dir` - The directory to extract the tgz file to.\n/// * `expected_hash` - Optional expected hash in format \"algorithm.hash\" (e.g., \"sha512.abcd1234...\")\n///\n/// # Returns\n/// * `Ok(())` - If the tgz file is downloaded, verified (if hash provided) and extracted successfully.\n/// * `Err(e)` - If the tgz file is not downloaded, verified or extracted successfully.\npub async fn download_and_extract_tgz_with_hash(\n    url: &str,\n    target_dir: impl AsRef<Path>,\n    expected_hash: Option<&str>,\n) -> Result<(), Error> {\n    let target_dir = target_dir.as_ref().to_path_buf();\n    tracing::debug!(\n        \"Start download and extract {} to {:?}, expected hash: {:?}\",\n        url,\n        target_dir,\n        expected_hash\n    );\n\n    // Create target directory\n    fs::create_dir_all(&target_dir).await?;\n\n    // Download the tgz file with retry logic using HttpClient\n    let tgz_file = target_dir.join(\"package.tgz\");\n    let client = HttpClient::new();\n    client.download_file(url, &tgz_file).await?;\n\n    // Verify hash if provided\n    if let Some(expected_hash) = expected_hash {\n        verify_file_hash(&tgz_file, expected_hash).await?;\n    }\n\n    // Extract the tgz file to the target directory\n    let tgz_file_for_extract = tgz_file.clone();\n    let target_dir_for_extract = target_dir.clone();\n    tokio::task::spawn_blocking(move || {\n        extract_tgz(&tgz_file_for_extract, &target_dir_for_extract)\n    })\n    .await??;\n\n    // Remove the temp file\n    fs::remove_file(&tgz_file).await?;\n    tracing::debug!(\"Download and extract finished\");\n    Ok(())\n}\n\n/// Computes the hash of the given content using the specified digest algorithm.\n///\n/// # Type Parameters\n/// * `D` - A type that implements the [`Digest`] trait, such as `Sha256`, `Sha512`, etc.\n///\n/// # Arguments\n/// * `content` - The byte slice to hash.\n///\n/// # Returns\n/// A hex-encoded string representing the computed digest.\nfn compute_hash<D: Digest>(content: &[u8]) -> String {\n    let mut hasher = D::new();\n    hasher.update(content);\n    hex::encode(hasher.finalize())\n}\n\n/// Verify the hash of a file against an expected hash.\n///\n/// # Arguments\n/// * `file_path` - Path to the file to verify\n/// * `expected_hash` - Expected hash in format \"algorithm.hash\" (e.g., \"sha512.abcd1234...\")\n///\n/// # Returns\n/// * `Ok(())` - If the file hash matches the expected hash\n/// * `Err(Error::HashMismatch)` - If the file hash doesn't match\npub async fn verify_file_hash(\n    file_path: impl AsRef<Path>,\n    expected_hash: &str,\n) -> Result<(), Error> {\n    let file_path = file_path.as_ref();\n    let content = fs::read(file_path).await?;\n\n    // Parse the hash format (e.g., \"sha512.abcd1234...\" or \"sha256.abcd1234...\")\n    let (algorithm, expected_hex) = if let Some((algo, hash)) = expected_hash.split_once('.') {\n        (algo, hash)\n    } else {\n        return Err(Error::InvalidHashFormat(expected_hash.into()));\n    };\n\n    // Calculate the actual hash based on the algorithm\n    let actual_hex = match algorithm {\n        \"sha512\" => compute_hash::<Sha512>(&content),\n        \"sha256\" => compute_hash::<Sha256>(&content),\n        \"sha224\" => compute_hash::<Sha224>(&content),\n        \"sha1\" => compute_hash::<Sha1>(&content),\n        _ => return Err(Error::UnsupportedHashAlgorithm(algorithm.into())),\n    };\n\n    if actual_hex != expected_hex {\n        return Err(Error::HashMismatch {\n            expected: expected_hash.into(),\n            actual: format!(\"{algorithm}.{actual_hex}\").into(),\n        });\n    }\n\n    tracing::debug!(\"Hash verification successful\");\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use std::fs;\n\n    use httpmock::prelude::*;\n    use tempfile::TempDir;\n\n    use super::*;\n\n    /// Helper function to create a mock package tar.gz that mimics npm package structure\n    fn create_mock_package_tgz() -> Vec<u8> {\n        let mut tar_builder = tar::Builder::new(Vec::new());\n\n        // Add package.json\n        let package_json = br#\"{\"name\":\"test-package\",\"version\":\"1.0.0\"}\"#;\n        let mut header = tar::Header::new_gnu();\n        header.set_size(package_json.len() as u64);\n        header.set_mode(0o644);\n        tar_builder\n            .append_data(&mut header, \"package/package.json\", std::io::Cursor::new(package_json))\n            .unwrap();\n\n        // Add bin/yarn mock file\n        let yarn_content = b\"#!/usr/bin/env node\\nconsole.log('mock yarn');\";\n        let mut header = tar::Header::new_gnu();\n        header.set_size(yarn_content.len() as u64);\n        header.set_mode(0o755);\n        tar_builder\n            .append_data(&mut header, \"package/bin/yarn\", std::io::Cursor::new(yarn_content))\n            .unwrap();\n\n        // Add bin/yarn.cmd mock file\n        let yarn_cmd_content = b\"@echo off\\nnode yarn %*\";\n        let mut header = tar::Header::new_gnu();\n        header.set_size(yarn_cmd_content.len() as u64);\n        header.set_mode(0o755);\n        tar_builder\n            .append_data(\n                &mut header,\n                \"package/bin/yarn.cmd\",\n                std::io::Cursor::new(yarn_cmd_content),\n            )\n            .unwrap();\n\n        let tar_data = tar_builder.into_inner().unwrap();\n\n        // Compress with gzip\n        let mut gz_data = Vec::new();\n        {\n            let mut encoder =\n                flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default());\n            std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap();\n        }\n        gz_data\n    }\n\n    #[tokio::test]\n    #[test_log::test]\n    async fn test_extract_tgz_function() {\n        // Test the extract_tgz function directly\n        let temp_dir = TempDir::new().unwrap();\n        let target_dir = temp_dir.path().join(\"extracted\");\n\n        // Create a simple tar.gz file content for testing\n        let test_content = b\"test file content\";\n        let mut tar_builder = tar::Builder::new(Vec::new());\n        let mut header = tar::Header::new_gnu();\n        header.set_size(test_content.len() as u64);\n        tar_builder\n            .append_data(&mut header, \"test.txt\", std::io::Cursor::new(test_content))\n            .unwrap();\n        let tar_data = tar_builder.into_inner().unwrap();\n\n        // Compress with gzip\n        let mut gz_data = Vec::new();\n        {\n            let mut encoder =\n                flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default());\n            std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap();\n        }\n\n        // Write the compressed data to a temporary file\n        let tgz_file = temp_dir.path().join(\"test.tgz\");\n        fs::write(&tgz_file, gz_data).unwrap();\n\n        // Test extraction\n        let result = extract_tgz(&tgz_file, &target_dir);\n        assert!(result.is_ok());\n\n        // Verify the file was extracted\n        let extracted_file = target_dir.join(\"test.txt\");\n        assert!(extracted_file.exists());\n\n        // Verify the content\n        let content = fs::read_to_string(extracted_file).unwrap();\n        assert_eq!(content, \"test file content\");\n    }\n\n    #[tokio::test]\n    async fn test_extract_tgz_large_file() {\n        // Test extraction with a larger file\n        let temp_dir = TempDir::new().unwrap();\n        let target_dir = temp_dir.path().join(\"extracted\");\n\n        // Create a larger tar.gz file for testing\n        let large_content = vec![b'a'; 1024 * 1024]; // 1MB\n        let mut tar_builder = tar::Builder::new(Vec::new());\n        let mut header = tar::Header::new_gnu();\n        header.set_size(large_content.len() as u64);\n        tar_builder\n            .append_data(&mut header, \"large.txt\", std::io::Cursor::new(&large_content))\n            .unwrap();\n        let tar_data = tar_builder.into_inner().unwrap();\n\n        // Compress with gzip\n        let mut gz_data = Vec::new();\n        {\n            let mut encoder =\n                flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default());\n            std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap();\n        }\n\n        // Write the compressed data to a temporary file\n        let tgz_file = temp_dir.path().join(\"large.tgz\");\n        fs::write(&tgz_file, gz_data).unwrap();\n\n        // Test extraction\n        let result = extract_tgz(&tgz_file, &target_dir);\n        assert!(result.is_ok());\n\n        // Verify the file was extracted\n        let extracted_file = target_dir.join(\"large.txt\");\n        assert!(extracted_file.exists());\n\n        // Verify the content size\n        let content = fs::read(extracted_file).unwrap();\n        assert_eq!(content.len(), 1024 * 1024);\n    }\n\n    #[tokio::test]\n    async fn test_extract_tgz_invalid_file() {\n        // Test extraction with invalid tar.gz content\n        let temp_dir = TempDir::new().unwrap();\n        let target_dir = temp_dir.path().join(\"extracted\");\n\n        // Create an invalid tar.gz file\n        let invalid_content = b\"this is not a valid tar.gz file\";\n        let tgz_file = temp_dir.path().join(\"invalid.tgz\");\n        fs::write(&tgz_file, invalid_content).unwrap();\n\n        // Test extraction - should fail gracefully\n        let result = extract_tgz(&tgz_file, &target_dir);\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_extract_tgz_empty_file() {\n        // Test extraction with empty tar.gz\n        let temp_dir = TempDir::new().unwrap();\n        let target_dir = temp_dir.path().join(\"extracted\");\n\n        // Create an empty tar.gz file\n        let tgz_file = temp_dir.path().join(\"empty.tgz\");\n        fs::write(&tgz_file, Vec::<u8>::new()).unwrap();\n\n        // Test extraction - should handle empty file gracefully\n        let result = extract_tgz(&tgz_file, &target_dir);\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_http_client_get_json() {\n        #[derive(serde::Deserialize, Debug, PartialEq)]\n        struct PackageInfo {\n            name: String,\n            version: String,\n            description: String,\n        }\n\n        let server = MockServer::start();\n\n        // Create mock JSON response\n        let mock_json = serde_json::json!({\n            \"name\": \"test-package\",\n            \"version\": \"1.0.0\",\n            \"description\": \"A test package\"\n        });\n\n        server.mock(|when, then| {\n            when.method(GET).path(\"/api/package.json\");\n            then.status(200)\n                .header(\"content-type\", \"application/json\")\n                .json_body(mock_json.clone());\n        });\n\n        let client = HttpClient::new();\n        let url = format!(\"{}/api/package.json\", server.base_url());\n\n        let result: Result<PackageInfo, _> = client.get_json(&url).await;\n        assert!(result.is_ok());\n\n        let package_info = result.unwrap();\n        assert_eq!(package_info.name, \"test-package\");\n        assert_eq!(package_info.version, \"1.0.0\");\n        assert_eq!(package_info.description, \"A test package\");\n    }\n\n    #[tokio::test]\n    async fn test_http_client_download_file() {\n        let server = MockServer::start();\n        let temp_dir = TempDir::new().unwrap();\n        let target_file = temp_dir.path().join(\"downloaded.txt\");\n\n        let mock_content = b\"Hello, World! This is test content.\";\n\n        server.mock(|when, then| {\n            when.method(GET).path(\"/file.txt\");\n            then.status(200).header(\"content-type\", \"text/plain\").body(mock_content);\n        });\n\n        let client = HttpClient::new();\n        let url = format!(\"{}/file.txt\", server.base_url());\n\n        let result = client.download_file(&url, &target_file).await;\n        assert!(result.is_ok(), \"Failed to download file: {result:?}\");\n\n        // Verify file exists and has correct content\n        assert!(target_file.exists());\n        let content = fs::read(&target_file).unwrap();\n        assert_eq!(content, mock_content);\n    }\n\n    #[tokio::test]\n    async fn test_http_client_retry_on_server_error() {\n        // Test that the client correctly retries on server errors\n        let server = MockServer::start();\n        let temp_dir = TempDir::new().unwrap();\n        let target_file = temp_dir.path().join(\"test.txt\");\n\n        server.mock(|when, then| {\n            when.method(GET).path(\"/server_error\");\n            then.status(500).body(\"Internal Server Error\");\n        });\n\n        let client = HttpClient::with_config(2, 50); // 2 retries with 50ms base interval\n        let url = format!(\"{}/server_error\", server.base_url());\n\n        // Should fail after retries\n        let result = client.download_file(&url, &target_file).await;\n        // println!(\"result: {:?}\", result);\n        assert!(result.is_err(), \"Expected download to fail with 500 after retries\");\n    }\n\n    #[tokio::test]\n    async fn test_download_and_extract_tgz() {\n        // Start a mock server\n        let server = MockServer::start();\n        let temp_dir = TempDir::new().unwrap();\n        let target_dir = temp_dir.path().join(\"extracted\");\n\n        // Create mock response with package tar.gz\n        let mock_tgz = create_mock_package_tgz();\n        server.mock(|when, then| {\n            when.method(GET).path(\"/test-package.tgz\");\n            then.status(200).header(\"content-type\", \"application/octet-stream\").body(mock_tgz);\n        });\n\n        let url = format!(\"{}/test-package.tgz\", server.base_url());\n        let result = download_and_extract_tgz_with_hash(&url, &target_dir, None).await;\n        assert!(result.is_ok(), \"Failed to download and extract: {result:?}\");\n\n        assert!(target_dir.join(\"package/bin/yarn\").exists());\n        assert!(target_dir.join(\"package/bin/yarn.cmd\").exists());\n\n        // TempDir automatically cleans up when it goes out of scope\n    }\n\n    #[tokio::test]\n    async fn test_verify_file_hash_sha1() {\n        use sha1::Sha1;\n        use sha2::Digest;\n        use tokio::io::AsyncWriteExt;\n\n        let temp_dir = TempDir::new().unwrap();\n        let test_file = temp_dir.path().join(\"test.txt\");\n\n        // Write test content\n        let content = b\"Hello, World!\";\n        let mut file = tokio::fs::File::create(&test_file).await.unwrap();\n        file.write_all(content).await.unwrap();\n\n        // Calculate expected SHA1\n        let mut hasher = Sha1::new();\n        hasher.update(content);\n        let expected_hash = format!(\"sha1.{:x}\", hasher.finalize());\n\n        // Test successful verification\n        let result = verify_file_hash(&test_file, &expected_hash).await;\n        assert!(result.is_ok());\n\n        // Test failed verification\n        let wrong_hash = \"sha1.0000000000000000000000000000000000000000\";\n        let result = verify_file_hash(&test_file, wrong_hash).await;\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_verify_file_hash_sha224() {\n        use sha2::{Digest, Sha224};\n        use tokio::io::AsyncWriteExt;\n\n        let temp_dir = TempDir::new().unwrap();\n        let test_file = temp_dir.path().join(\"test.txt\");\n\n        // Write test content\n        let content = b\"Test content for SHA224\";\n        let mut file = tokio::fs::File::create(&test_file).await.unwrap();\n        file.write_all(content).await.unwrap();\n\n        // Calculate expected SHA224\n        let mut hasher = Sha224::new();\n        hasher.update(content);\n        let expected_hash = format!(\"sha224.{:x}\", hasher.finalize());\n\n        // Test successful verification\n        let result = verify_file_hash(&test_file, &expected_hash).await;\n        assert!(result.is_ok());\n\n        // Test failed verification\n        let wrong_hash = \"sha224.00000000000000000000000000000000000000000000000000000000\";\n        let result = verify_file_hash(&test_file, wrong_hash).await;\n        assert!(result.is_err());\n    }\n\n    #[tokio::test]\n    async fn test_http_client_download_with_404_error() {\n        let server = MockServer::start();\n        let temp_dir = TempDir::new().unwrap();\n        let target_file = temp_dir.path().join(\"test.txt\");\n\n        // Mock a 404 response\n        let mock = server.mock(|when, then| {\n            when.method(GET).path(\"/nonexistent\");\n            then.status(404).body(\"Not Found\");\n        });\n\n        let client = HttpClient::new();\n        let url = format!(\"{}/nonexistent\", server.base_url());\n\n        // Should fail with 404\n        let result = client.download_file(&url, &target_file).await;\n        assert!(result.is_err(), \"Expected download to fail with 404\");\n\n        // Should try 4 times, 1 for first request, 3 for retries\n        mock.assert_hits(4);\n    }\n\n    #[tokio::test]\n    async fn test_http_client_json_with_invalid_response() {\n        #[derive(serde::Deserialize)]\n        struct TestData {\n            _field: String,\n        }\n\n        let server = MockServer::start();\n\n        // Mock response with invalid JSON\n        server.mock(|when, then| {\n            when.method(GET).path(\"/invalid.json\");\n            then.status(200).header(\"content-type\", \"application/json\").body(\"not valid json\");\n        });\n\n        let client = HttpClient::new();\n        let url = format!(\"{}/invalid.json\", server.base_url());\n\n        let result: Result<TestData, _> = client.get_json(&url).await;\n        assert!(result.is_err(), \"Expected JSON parsing to fail\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_install/src/shim.rs",
    "content": "use std::path::Path;\n\nuse indoc::formatdoc;\nuse pathdiff::diff_paths;\nuse tokio::fs::write;\nuse vite_error::Error;\n\n/// Write cmd/sh/pwsh shim files.\npub async fn write_shims(\n    source_file: impl AsRef<Path>,\n    to_bin: impl AsRef<Path>,\n) -> Result<(), Error> {\n    let to_bin = to_bin.as_ref();\n    // source file `/foo/bar/pnpm.js` point to bin file `/foo/bin/npm`, the relative path is `../bar/pnpm.js`.\n    let relative_path = diff_paths(source_file, to_bin.parent().unwrap()).unwrap();\n    let relative_file = relative_path.to_str().unwrap();\n\n    // Referenced from pnpm/cmd-shim's TypeScript implementation:\n    // https://github.com/pnpm/cmd-shim/blob/main/src/index.ts\n    write(to_bin, sh_shim(relative_file)).await?;\n    write(to_bin.with_extension(\"cmd\"), cmd_shim(relative_file)).await?;\n    write(to_bin.with_extension(\"ps1\"), pwsh_shim(relative_file)).await?;\n\n    // set executable permission for unix\n    #[cfg(unix)]\n    {\n        use std::os::unix::fs::PermissionsExt;\n        tokio::fs::set_permissions(to_bin, std::fs::Permissions::from_mode(0o755)).await?;\n    }\n\n    tracing::debug!(\"write_shims: {:?} -> {:?}\", to_bin, relative_file);\n    Ok(())\n}\n\n/// Unix shell shim.\npub fn sh_shim(relative_file: &str) -> String {\n    formatdoc! {\n        r#\"\n        #!/bin/sh\n        basedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\n        case `uname` in\n            *CYGWIN*|*MINGW*|*MSYS*)\n                if command -v cygpath > /dev/null 2>&1; then\n                    basedir=`cygpath -w \"$basedir\"`\n                fi\n            ;;\n        esac\n\n        if [ -x \"$basedir/node\" ]; then\n            exec \"$basedir/node\" \"$basedir/{relative_file}\" \"$@\"\n        else\n            exec node \"$basedir/{relative_file}\" \"$@\"\n        fi\n        \"#\n    }\n}\n\n/// Windows Command Prompt shim.\npub fn cmd_shim(relative_file: &str) -> String {\n    formatdoc! {\n        r#\"\n        @SETLOCAL\n        @IF EXIST \"%~dp0\\node.exe\" (\n            \"%~dp0\\node.exe\" \"%~dp0\\{relative_file}\" %*\n        ) ELSE (\n        @SET PATHEXT=%PATHEXT:;.JS;=;%\n            node \"%~dp0\\{relative_file}\" %*\n        )\n        \"#,\n        relative_file = relative_file.replace('/', \"\\\\\")\n    }\n    .replace('\\n', \"\\r\\n\") // replace \\n to \\r\\n for windows\n}\n\n/// `PowerShell` shim.\npub fn pwsh_shim(relative_file: &str) -> String {\n    formatdoc! {\n        r#\"\n        #!/usr/bin/env pwsh\n        $basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\n        $exe=\"\"\n        if ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {{\n            # Fix case when both the Windows and Linux builds of Node\n            # are installed in the same directory\n            $exe=\".exe\"\n        }}\n        $ret=0\n        if (Test-Path \"$basedir/node$exe\") {{\n            # Support pipeline input\n            if ($MyInvocation.ExpectingInput) {{\n                $input | & \"$basedir/node$exe\" \"$basedir/{relative_file}\" $args\n            }} else {{\n                & \"$basedir/node$exe\" \"$basedir/{relative_file}\" $args\n            }}\n            $ret=$LASTEXITCODE\n        }} else {{\n            # Support pipeline input\n            if ($MyInvocation.ExpectingInput) {{\n                $input | & \"node$exe\" \"$basedir/{relative_file}\" $args\n            }} else {{\n                & \"node$exe\" \"$basedir/{relative_file}\" $args\n            }}\n            $ret=$LASTEXITCODE\n        }}\n        exit $ret\n        \"#\n    }\n}\n\n#[cfg(test)]\n#[cfg(not(windows))] // FIXME\nmod tests {\n    use tempfile::TempDir;\n    use tokio::fs::read_to_string;\n\n    use super::*;\n\n    fn format_shim(shim: &str) -> String {\n        shim.replace(' ', \"·\")\n    }\n\n    #[test]\n    fn test_sh_shim() {\n        let shim = sh_shim(\"pnpm.js\");\n        // println!(\"{:#}\", format_shim(&shim));\n        assert!(shim.contains(\"pnpm.js\"), \"{}\", format_shim(&shim));\n    }\n\n    #[test]\n    fn test_cmd_shim() {\n        let shim = cmd_shim(\"yarn.js\");\n        // println!(\"{:#?}\", format_shim(&shim));\n        assert!(shim.contains(\"yarn.js\"), \"{}\", format_shim(&shim));\n        assert!(\n            shim.contains(\"@SETLOCAL\\r\\n@IF EXIST \\\"%~dp0\\\\node.exe\\\" (\\r\\n\"),\n            \"{}\",\n            format_shim(&shim)\n        );\n\n        let shim = cmd_shim(\"../../../../pnpm.js\");\n        // println!(\"{:#}\", format_shim(&shim));\n        assert!(\n            shim.contains(\"node \\\"%~dp0\\\\..\\\\..\\\\..\\\\..\\\\pnpm.js\\\" %*\"),\n            \"{}\",\n            format_shim(&shim)\n        );\n        assert!(\n            shim.contains(\"@SETLOCAL\\r\\n@IF EXIST \\\"%~dp0\\\\node.exe\\\" (\\r\\n\"),\n            \"{}\",\n            format_shim(&shim)\n        );\n    }\n\n    #[test]\n    fn test_pwsh_shim() {\n        let shim = pwsh_shim(\"pnpm.cjs\");\n        // println!(\"{:#}\", format_shim(&shim));\n        assert!(shim.contains(\"pnpm.cjs\"), \"{}\", format_shim(&shim));\n    }\n\n    #[tokio::test]\n    async fn test_write_shims_basic() {\n        let temp_dir = TempDir::new().unwrap();\n        let source = temp_dir.path().join(\"node_modules\").join(\".bin\").join(\"pnpm.js\");\n        let target = temp_dir.path().join(\"bin\").join(\"pnpm\");\n\n        // Create parent directories\n        tokio::fs::create_dir_all(source.parent().unwrap()).await.unwrap();\n        tokio::fs::create_dir_all(target.parent().unwrap()).await.unwrap();\n\n        // Write shims\n        write_shims(&source, &target).await.unwrap();\n\n        // Verify base shim file was created (shell script)\n        assert!(target.exists());\n        let content = read_to_string(&target).await.unwrap();\n        assert!(content.contains(\"#!/bin/sh\"));\n        assert!(content.contains(\"../node_modules/.bin/pnpm.js\"));\n\n        // Verify .cmd file was created\n        let cmd_file = target.with_extension(\"cmd\");\n        assert!(cmd_file.exists());\n        let cmd_content = read_to_string(&cmd_file).await.unwrap();\n        assert!(cmd_content.contains(\"@SETLOCAL\"));\n        assert!(cmd_content.contains(\"..\\\\node_modules\\\\.bin\\\\pnpm.js\"));\n\n        // Verify .ps1 file was created\n        let ps1_file = target.with_extension(\"ps1\");\n        assert!(ps1_file.exists());\n        let ps1_content = read_to_string(&ps1_file).await.unwrap();\n        assert!(ps1_content.contains(\"#!/usr/bin/env pwsh\"));\n        assert!(ps1_content.contains(\"../node_modules/.bin/pnpm.js\"));\n    }\n\n    #[tokio::test]\n    async fn test_write_shims_relative_paths() {\n        let temp_dir = TempDir::new().unwrap();\n\n        // Test case 1: Source is deeper than target\n        let source1 = temp_dir.path().join(\"deep\").join(\"nested\").join(\"path\").join(\"script.js\");\n        let target1 = temp_dir.path().join(\"bin\").join(\"script\");\n\n        tokio::fs::create_dir_all(source1.parent().unwrap()).await.unwrap();\n        tokio::fs::create_dir_all(target1.parent().unwrap()).await.unwrap();\n\n        write_shims(&source1, &target1).await.unwrap();\n\n        let content1 = read_to_string(&target1).await.unwrap();\n        assert!(content1.contains(\"../deep/nested/path/script.js\"));\n\n        // Test case 2: Source and target at same level\n        let source2 = temp_dir.path().join(\"scripts\").join(\"tool.js\");\n        let target2 = temp_dir.path().join(\"bin\").join(\"tool\");\n\n        tokio::fs::create_dir_all(source2.parent().unwrap()).await.unwrap();\n        tokio::fs::create_dir_all(target2.parent().unwrap()).await.unwrap();\n\n        write_shims(&source2, &target2).await.unwrap();\n\n        let content2 = read_to_string(&target2).await.unwrap();\n        assert!(content2.contains(\"../scripts/tool.js\"));\n    }\n\n    #[tokio::test]\n    async fn test_write_shims_windows_path_conversion() {\n        let temp_dir = TempDir::new().unwrap();\n        let source = temp_dir.path().join(\"node_modules\").join(\"package\").join(\"bin.js\");\n        let target = temp_dir.path().join(\"bin\").join(\"package\");\n\n        tokio::fs::create_dir_all(source.parent().unwrap()).await.unwrap();\n        tokio::fs::create_dir_all(target.parent().unwrap()).await.unwrap();\n\n        write_shims(&source, &target).await.unwrap();\n\n        // Check base file (shell script) has forward slashes\n        let content = read_to_string(&target).await.unwrap();\n        assert!(content.contains(\"../node_modules/package/bin.js\"));\n\n        // Check CMD file has backslashes\n        let cmd_file = target.with_extension(\"cmd\");\n        let cmd_content = read_to_string(&cmd_file).await.unwrap();\n        assert!(cmd_content.contains(\"..\\\\node_modules\\\\package\\\\bin.js\"));\n        assert!(!cmd_content.contains(\"../node_modules/package/bin.js\"));\n\n        // Check PS1 file has forward slashes\n        let ps1_file = target.with_extension(\"ps1\");\n        let ps1_content = read_to_string(&ps1_file).await.unwrap();\n        assert!(ps1_content.contains(\"../node_modules/package/bin.js\"));\n        assert!(!ps1_content.contains(\"..\\\\node_modules\\\\\"));\n    }\n\n    #[tokio::test]\n    async fn test_write_shims_overwrite_existing() {\n        let temp_dir = TempDir::new().unwrap();\n        let source = temp_dir.path().join(\"src\").join(\"cli.js\");\n        let target = temp_dir.path().join(\"bin\").join(\"cli\");\n\n        tokio::fs::create_dir_all(source.parent().unwrap()).await.unwrap();\n        tokio::fs::create_dir_all(target.parent().unwrap()).await.unwrap();\n\n        // Write initial content to files\n        tokio::fs::write(&target, \"old content\").await.unwrap();\n        tokio::fs::write(target.with_extension(\"cmd\"), \"old cmd content\").await.unwrap();\n        tokio::fs::write(target.with_extension(\"ps1\"), \"old ps1 content\").await.unwrap();\n\n        // Write shims (should overwrite)\n        write_shims(&source, &target).await.unwrap();\n\n        // Verify files were overwritten\n        let content = read_to_string(&target).await.unwrap();\n        assert!(!content.contains(\"old content\"));\n        assert!(content.contains(\"../src/cli.js\"));\n\n        let cmd_content = read_to_string(target.with_extension(\"cmd\")).await.unwrap();\n        assert!(!cmd_content.contains(\"old cmd content\"));\n        assert!(cmd_content.contains(\"@SETLOCAL\"));\n\n        let ps1_content = read_to_string(target.with_extension(\"ps1\")).await.unwrap();\n        assert!(!ps1_content.contains(\"old ps1 content\"));\n        assert!(ps1_content.contains(\"#!/usr/bin/env pwsh\"));\n    }\n\n    #[tokio::test]\n    async fn test_write_shims_complex_relative_path() {\n        let temp_dir = TempDir::new().unwrap();\n        let source = temp_dir.path().join(\"a\").join(\"b\").join(\"c\").join(\"script.js\");\n        let target = temp_dir.path().join(\"x\").join(\"y\").join(\"z\").join(\"script\");\n\n        tokio::fs::create_dir_all(source.parent().unwrap()).await.unwrap();\n        tokio::fs::create_dir_all(target.parent().unwrap()).await.unwrap();\n\n        write_shims(&source, &target).await.unwrap();\n\n        // Base file should be shell script with forward slashes\n        let content = read_to_string(&target).await.unwrap();\n        assert!(content.contains(\"#!/bin/sh\"));\n        assert!(content.contains(\"../../a/b/c/script.js\"));\n\n        // CMD file should have backslashes\n        let cmd_content = read_to_string(target.with_extension(\"cmd\")).await.unwrap();\n        assert!(cmd_content.contains(\"..\\\\..\\\\a\\\\b\\\\c\\\\script.js\"));\n    }\n\n    #[tokio::test]\n    async fn test_sh_shim_content_validation() {\n        let shim = sh_shim(\"lib/cli.js\");\n\n        // Verify shebang\n        assert!(shim.starts_with(\"#!/bin/sh\"));\n\n        // Verify CYGWIN/MINGW/MSYS handling\n        assert!(shim.contains(\"*CYGWIN*|*MINGW*|*MSYS*)\"));\n        assert!(shim.contains(\"cygpath -w\"));\n\n        // Verify node execution paths\n        assert!(shim.contains(\"if [ -x \\\"$basedir/node\\\" ]\"));\n        assert!(shim.contains(\"exec \\\"$basedir/node\\\" \\\"$basedir/lib/cli.js\\\" \\\"$@\\\"\"));\n        assert!(shim.contains(\"exec node \\\"$basedir/lib/cli.js\\\" \\\"$@\\\"\"));\n    }\n\n    #[tokio::test]\n    async fn test_cmd_shim_content_validation() {\n        let shim = cmd_shim(\"lib/cli.js\");\n\n        // Verify Windows batch commands\n        assert!(shim.starts_with(\"@SETLOCAL\"));\n        assert!(shim.contains(\"@IF EXIST \\\"%~dp0\\\\node.exe\\\"\"));\n        assert!(shim.contains(\"\\\"%~dp0\\\\node.exe\\\" \\\"%~dp0\\\\lib\\\\cli.js\\\" %*\"));\n        assert!(shim.contains(\"@SET PATHEXT=%PATHEXT:;.JS;=;%\"));\n        assert!(shim.contains(\"node \\\"%~dp0\\\\lib\\\\cli.js\\\" %*\"));\n\n        // Verify line endings are Windows-style\n        assert!(shim.contains(\"\\r\\n\"));\n        assert!(!shim.contains(\"\\n\\n\")); // No double Unix line endings\n    }\n\n    #[tokio::test]\n    async fn test_pwsh_shim_content_validation() {\n        let shim = pwsh_shim(\"lib/cli.js\");\n\n        // Verify shebang\n        assert!(shim.starts_with(\"#!/usr/bin/env pwsh\"));\n\n        // Verify PowerShell version handling\n        assert!(shim.contains(\"$PSVersionTable.PSVersion -lt \\\"6.0\\\"\"));\n        assert!(shim.contains(\"$IsWindows\"));\n\n        // Verify execution paths\n        assert!(shim.contains(\"Test-Path \\\"$basedir/node$exe\\\"\"));\n        assert!(shim.contains(\"& \\\"$basedir/node$exe\\\" \\\"$basedir/lib/cli.js\\\" $args\"));\n\n        // Verify pipeline input support\n        assert!(shim.contains(\"$MyInvocation.ExpectingInput\"));\n        assert!(shim.contains(\"$input |\"));\n\n        // Verify exit code handling\n        assert!(shim.contains(\"$ret=$LASTEXITCODE\"));\n        assert!(shim.contains(\"exit $ret\"));\n    }\n\n    #[tokio::test]\n    async fn test_write_shims_error_handling() {\n        // Test with invalid path (no parent directory)\n        let temp_dir = TempDir::new().unwrap();\n        let source = temp_dir.path().join(\"source.js\");\n        let target = temp_dir.path().join(\"non\").join(\"existent\").join(\"path\").join(\"target\");\n\n        // This should fail because parent directory doesn't exist\n        let result = write_shims(&source, &target).await;\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_cmd_shim_path_separator_conversion() {\n        // Test forward slashes are converted to backslashes\n        let shim = cmd_shim(\"node_modules/.bin/tool.js\");\n        assert!(shim.contains(\"node_modules\\\\.bin\\\\tool.js\"));\n        assert!(!shim.contains(\"node_modules/.bin/tool.js\"));\n\n        // Test multiple levels\n        let shim = cmd_shim(\"a/b/c/d.js\");\n        assert!(shim.contains(\"a\\\\b\\\\c\\\\d.js\"));\n        assert!(!shim.contains(\"a/b/c/d.js\"));\n    }\n\n    #[test]\n    fn test_relative_path_formats() {\n        // Test various relative path formats work correctly\n        let paths = vec![\n            \"../script.js\",\n            \"../../lib/cli.js\",\n            \"../../../node_modules/.bin/tool.js\",\n            \"script.js\",\n            \"./script.js\",\n        ];\n\n        for path in paths {\n            let sh = sh_shim(path);\n            assert!(sh.contains(path));\n\n            let ps1 = pwsh_shim(path);\n            assert!(ps1.contains(path));\n\n            let cmd = cmd_shim(path);\n            let expected_cmd_path = path.replace('/', \"\\\\\");\n            assert!(cmd.contains(&expected_cmd_path));\n        }\n    }\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/Cargo.toml",
    "content": "[package]\nname = \"vite_js_runtime\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\nasync-trait = { workspace = true }\nbackon = { workspace = true }\nflate2 = { workspace = true }\nfutures-util = { workspace = true }\nindicatif = { workspace = true }\nhex = { workspace = true }\nnode-semver = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true, features = [\"preserve_order\"] }\nsha2 = { workspace = true }\ntar = { workspace = true }\ntempfile = { workspace = true }\nthiserror = { workspace = true }\ntokio = { workspace = true, features = [\"full\"] }\ntracing = { workspace = true }\nvite_path = { workspace = true }\nvite_shared = { workspace = true }\nvite_str = { workspace = true }\nzip = { workspace = true }\n\n[target.'cfg(target_os = \"windows\")'.dependencies]\nreqwest = { workspace = true, features = [\"stream\", \"native-tls-vendored\"] }\n\n[target.'cfg(not(target_os = \"windows\"))'.dependencies]\nreqwest = { workspace = true, features = [\"stream\", \"rustls-tls\"] }\n\n[dev-dependencies]\ntempfile = { workspace = true }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/vite_js_runtime/src/cache.rs",
    "content": "//! Cache directory utilities for JavaScript runtimes.\n\nuse vite_path::AbsolutePathBuf;\n\nuse crate::Error;\n\n/// Get the cache directory for JavaScript runtimes.\n///\n/// Returns `$VITE_PLUS_HOME/js_runtime`.\npub(crate) fn get_cache_dir() -> Result<AbsolutePathBuf, Error> {\n    Ok(vite_shared::get_vite_plus_home()?.join(\"js_runtime\"))\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/dev_engines.rs",
    "content": "//! `.node-version` file reading and writing utilities.\n//!\n//! This module provides utilities for working with `.node-version` files,\n//! which are used to specify Node.js versions for projects.\n//!\n//! For PackageJson types (devEngines, engines), see `vite_shared::package_json`.\n\nuse vite_path::AbsolutePath;\n// Re-export shared types for internal use\npub(crate) use vite_shared::PackageJson;\nuse vite_str::Str;\n\nuse crate::Error;\n\n/// Parse the content of a `.node-version` file.\n///\n/// # Supported Formats\n///\n/// - Three-part version: `20.5.0`\n/// - With `v` prefix: `v20.5.0`\n/// - Two-part version: `20.5` (treated as `^20.5.0` for resolution)\n/// - Single-part version: `20` (treated as `^20.0.0` for resolution)\n/// - LTS aliases: `lts/*`, `lts/iron`, `lts/jod`, `lts/-1`\n///\n/// # Returns\n///\n/// The version string with any leading `v` prefix stripped (for regular versions).\n/// LTS aliases are preserved as-is (e.g., `lts/iron` stays `lts/iron`).\n/// Returns `None` if the content is empty or contains only whitespace.\n#[must_use]\npub fn parse_node_version_content(content: &str) -> Option<Str> {\n    let version = content.lines().next()?.trim();\n    if version.is_empty() {\n        return None;\n    }\n\n    // Preserve LTS aliases as-is (lts/*, lts/iron, lts/-1, etc.)\n    if version.starts_with(\"lts/\") {\n        return Some(version.into());\n    }\n\n    // Strip optional 'v' prefix for regular versions\n    let version = version.strip_prefix('v').unwrap_or(version);\n    Some(version.into())\n}\n\n/// Read and parse a `.node-version` file from the project root.\n///\n/// # Arguments\n/// * `project_path` - The path to the project directory\n///\n/// # Returns\n/// The version string if the file exists and contains a valid version.\npub async fn read_node_version_file(project_path: &AbsolutePath) -> Option<Str> {\n    let path = project_path.join(\".node-version\");\n    let content = tokio::fs::read_to_string(&path).await.ok()?;\n    parse_node_version_content(&content)\n}\n\n/// Write a version to the `.node-version` file.\n///\n/// Creates the file if it doesn't exist, overwrites if it does.\n/// Uses three-part version without `v` prefix and Unix line ending.\n///\n/// # Arguments\n/// * `project_path` - The path to the project directory\n/// * `version` - The version string (e.g., \"22.13.1\")\n///\n/// # Errors\n/// Returns an error if the file cannot be written.\npub async fn write_node_version_file(\n    project_path: &AbsolutePath,\n    version: &str,\n) -> Result<(), Error> {\n    let path = project_path.join(\".node-version\");\n    tokio::fs::write(&path, format!(\"{version}\\n\")).await?;\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n    use vite_path::AbsolutePathBuf;\n\n    use super::*;\n\n    #[test]\n    fn test_parse_node_version_content_three_part() {\n        assert_eq!(parse_node_version_content(\"20.5.0\\n\"), Some(\"20.5.0\".into()));\n        assert_eq!(parse_node_version_content(\"20.5.0\"), Some(\"20.5.0\".into()));\n        assert_eq!(parse_node_version_content(\"22.13.1\\n\"), Some(\"22.13.1\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_with_v_prefix() {\n        assert_eq!(parse_node_version_content(\"v20.5.0\\n\"), Some(\"20.5.0\".into()));\n        assert_eq!(parse_node_version_content(\"v20.5.0\"), Some(\"20.5.0\".into()));\n        assert_eq!(parse_node_version_content(\"v22.13.1\\n\"), Some(\"22.13.1\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_two_part() {\n        assert_eq!(parse_node_version_content(\"20.5\\n\"), Some(\"20.5\".into()));\n        assert_eq!(parse_node_version_content(\"v20.5\\n\"), Some(\"20.5\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_single_part() {\n        assert_eq!(parse_node_version_content(\"20\\n\"), Some(\"20\".into()));\n        assert_eq!(parse_node_version_content(\"v20\\n\"), Some(\"20\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_with_whitespace() {\n        assert_eq!(parse_node_version_content(\"  20.5.0  \\n\"), Some(\"20.5.0\".into()));\n        assert_eq!(parse_node_version_content(\"\\t20.5.0\\t\\n\"), Some(\"20.5.0\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_empty() {\n        assert!(parse_node_version_content(\"\").is_none());\n        assert!(parse_node_version_content(\"\\n\").is_none());\n        assert!(parse_node_version_content(\"   \\n\").is_none());\n    }\n\n    #[tokio::test]\n    async fn test_read_node_version_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // File doesn't exist\n        assert!(read_node_version_file(&temp_path).await.is_none());\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"22.13.1\\n\").await.unwrap();\n        assert_eq!(read_node_version_file(&temp_path).await, Some(\"22.13.1\".into()));\n    }\n\n    #[tokio::test]\n    async fn test_write_node_version_file() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        write_node_version_file(&temp_path, \"22.13.1\").await.unwrap();\n\n        let content = tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(content, \"22.13.1\\n\");\n\n        // Verify it can be read back\n        assert_eq!(read_node_version_file(&temp_path).await, Some(\"22.13.1\".into()));\n    }\n\n    // ========================================================================\n    // LTS Alias Tests - These test support for lts/* syntax in .node-version\n    // ========================================================================\n\n    #[test]\n    fn test_parse_node_version_content_lts_latest() {\n        // lts/* should be preserved as-is (not stripped of prefix)\n        assert_eq!(parse_node_version_content(\"lts/*\\n\"), Some(\"lts/*\".into()));\n        assert_eq!(parse_node_version_content(\"lts/*\"), Some(\"lts/*\".into()));\n        assert_eq!(parse_node_version_content(\"  lts/*  \\n\"), Some(\"lts/*\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_lts_codename() {\n        // lts/<codename> should be preserved as-is\n        assert_eq!(parse_node_version_content(\"lts/iron\\n\"), Some(\"lts/iron\".into()));\n        assert_eq!(parse_node_version_content(\"lts/jod\\n\"), Some(\"lts/jod\".into()));\n        assert_eq!(parse_node_version_content(\"lts/hydrogen\\n\"), Some(\"lts/hydrogen\".into()));\n        // Should preserve original case for codenames\n        assert_eq!(parse_node_version_content(\"lts/Iron\\n\"), Some(\"lts/Iron\".into()));\n        assert_eq!(parse_node_version_content(\"lts/Jod\\n\"), Some(\"lts/Jod\".into()));\n    }\n\n    #[test]\n    fn test_parse_node_version_content_lts_offset() {\n        // lts/-n should be preserved as-is\n        assert_eq!(parse_node_version_content(\"lts/-1\\n\"), Some(\"lts/-1\".into()));\n        assert_eq!(parse_node_version_content(\"lts/-2\\n\"), Some(\"lts/-2\".into()));\n    }\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/download.rs",
    "content": "//! Generic download utilities for JavaScript runtime management.\n//!\n//! This module provides platform-agnostic utilities for downloading,\n//! verifying, and extracting runtime archives.\n\nuse std::{fs::File, io::IsTerminal, time::Duration};\n\nuse backon::{ExponentialBuilder, Retryable};\nuse futures_util::StreamExt;\nuse indicatif::{ProgressBar, ProgressStyle};\nuse sha2::{Digest, Sha256};\nuse tokio::{fs, io::AsyncWriteExt};\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_str::Str;\n\nuse crate::{Error, provider::ArchiveFormat};\n\n/// Response from a cached fetch operation\npub struct CachedFetchResponse {\n    /// Response body (None if 304 Not Modified)\n    #[expect(clippy::disallowed_types, reason = \"HTTP response body is a String\")]\n    pub body: Option<String>,\n    /// ETag header value\n    pub etag: Option<Str>,\n    /// Cache max-age in seconds (from Cache-Control header)\n    pub max_age: Option<u64>,\n    /// Whether this was a 304 Not Modified response\n    pub not_modified: bool,\n}\n\n/// Download a file with retry logic and progress bar\n///\n/// The `message` parameter is displayed to the user to indicate what is being downloaded\n/// (e.g., \"Downloading Node.js v22.13.1\").\npub async fn download_file(\n    url: &str,\n    target_path: &AbsolutePath,\n    message: &str,\n) -> Result<(), Error> {\n    tracing::debug!(\"Downloading {url} to {target_path:?}\");\n\n    let response = (|| async { reqwest::get(url).await?.error_for_status() })\n        .retry(\n            ExponentialBuilder::default()\n                .with_jitter()\n                .with_min_delay(Duration::from_millis(500))\n                .with_max_times(3),\n        )\n        .await\n        .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!(\"{e}\") })?;\n\n    // Get Content-Length for progress bar\n    let total_size = response.content_length();\n\n    // Create progress bar (only in TTY and not in CI)\n    let is_ci = vite_shared::EnvConfig::get().is_ci;\n    let progress = if std::io::stderr().is_terminal() && !is_ci {\n        let pb = match total_size {\n            Some(size) => {\n                let pb = ProgressBar::new(size);\n                pb.set_style(\n                    ProgressStyle::default_bar()\n                        .template(\n                            \"{msg}\\n{spinner:.green} [{elapsed_precise}] [{bar:40.blue/white}] \\\n                             {bytes}/{total_bytes} ({bytes_per_sec}, {eta})\",\n                        )\n                        .expect(\"valid progress bar template\")\n                        .progress_chars(\"#>-\"),\n                );\n                pb\n            }\n            None => {\n                let pb = ProgressBar::new_spinner();\n                pb.set_style(\n                    ProgressStyle::default_spinner()\n                        .template(\n                            \"{msg}\\n{spinner:.green} [{elapsed_precise}] {bytes} ({bytes_per_sec})\",\n                        )\n                        .expect(\"valid spinner template\"),\n                );\n                pb.enable_steady_tick(Duration::from_millis(100));\n                pb\n            }\n        };\n        pb.set_message(message.to_string());\n        Some(pb)\n    } else {\n        None\n    };\n\n    // Stream to file with progress updates\n    let mut file = fs::File::create(target_path).await?;\n    let mut stream = response.bytes_stream();\n\n    while let Some(chunk_result) = stream.next().await {\n        let chunk = chunk_result?;\n        if let Some(ref pb) = progress {\n            pb.inc(chunk.len() as u64);\n        }\n        file.write_all(&chunk).await?;\n    }\n\n    file.flush().await?;\n\n    if let Some(pb) = progress {\n        pb.finish_and_clear();\n    }\n\n    tracing::debug!(\"Download completed: {target_path:?}\");\n\n    Ok(())\n}\n\n/// Download text content from a URL with retry logic\n#[expect(clippy::disallowed_types, reason = \"HTTP response body is a String\")]\npub async fn download_text(url: &str) -> Result<String, Error> {\n    tracing::debug!(\"Downloading text from {url}\");\n\n    let content = (|| async { reqwest::get(url).await?.text().await })\n        .retry(\n            ExponentialBuilder::default()\n                .with_jitter()\n                .with_min_delay(Duration::from_millis(500))\n                .with_max_times(3),\n        )\n        .await\n        .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!(\"{e}\") })?;\n\n    Ok(content)\n}\n\n/// Fetch text with conditional request support\n///\n/// If `if_none_match` is provided, sends `If-None-Match` header for conditional request.\n/// Returns response with cache headers and not_modified flag.\npub async fn fetch_with_cache_headers(\n    url: &str,\n    if_none_match: Option<&str>,\n) -> Result<CachedFetchResponse, Error> {\n    tracing::debug!(\"Fetching with cache headers from {url}\");\n\n    let response = (|| async {\n        let client = reqwest::Client::new();\n        let mut request = client.get(url);\n\n        if let Some(etag) = if_none_match {\n            request = request.header(\"If-None-Match\", etag);\n        }\n\n        request.send().await\n    })\n    .retry(\n        ExponentialBuilder::default()\n            .with_jitter()\n            .with_min_delay(Duration::from_millis(500))\n            .with_max_times(3),\n    )\n    .await\n    .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!(\"{e}\") })?;\n\n    // Check for 304 Not Modified\n    if response.status() == reqwest::StatusCode::NOT_MODIFIED {\n        tracing::debug!(\"Received 304 Not Modified for {url}\");\n        return Ok(CachedFetchResponse {\n            body: None,\n            etag: None,\n            max_age: None,\n            not_modified: true,\n        });\n    }\n\n    // Extract headers before consuming response\n    let etag = response.headers().get(\"etag\").and_then(|v| v.to_str().ok()).map(|s| s.into());\n\n    let max_age = response\n        .headers()\n        .get(\"cache-control\")\n        .and_then(|v| v.to_str().ok())\n        .and_then(parse_max_age);\n\n    let body = response\n        .text()\n        .await\n        .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!(\"{e}\") })?;\n\n    Ok(CachedFetchResponse { body: Some(body), etag, max_age, not_modified: false })\n}\n\n/// Parse max-age from Cache-Control header value\n/// Example: \"public, max-age=300\" -> Some(300)\nfn parse_max_age(cache_control: &str) -> Option<u64> {\n    for directive in cache_control.split(',') {\n        let directive = directive.trim();\n        if let Some(value) = directive.strip_prefix(\"max-age=\") {\n            return value.trim().parse().ok();\n        }\n    }\n    None\n}\n\n/// Verify file hash against expected SHA256 hash\npub async fn verify_file_hash(\n    file_path: &AbsolutePath,\n    expected_hash: &str,\n    filename: &str,\n) -> Result<(), Error> {\n    tracing::debug!(\"Verifying hash for {filename}\");\n\n    let content = fs::read(file_path).await?;\n\n    let mut hasher = Sha256::new();\n    hasher.update(&content);\n    let actual_hash: Str = hex::encode(hasher.finalize()).into();\n\n    if actual_hash != expected_hash {\n        return Err(Error::HashMismatch {\n            filename: filename.into(),\n            expected: expected_hash.into(),\n            actual: actual_hash,\n        });\n    }\n\n    tracing::debug!(\"Hash verification successful for {filename}\");\n    Ok(())\n}\n\n/// Extract archive based on format\npub async fn extract_archive(\n    archive_path: &AbsolutePath,\n    target_dir: &AbsolutePath,\n    format: ArchiveFormat,\n) -> Result<(), Error> {\n    let archive_path = AbsolutePathBuf::new(archive_path.as_path().to_path_buf()).unwrap();\n    let target_dir = AbsolutePathBuf::new(target_dir.as_path().to_path_buf()).unwrap();\n\n    tokio::task::spawn_blocking(move || match format {\n        ArchiveFormat::Zip => extract_zip(&archive_path, &target_dir),\n        ArchiveFormat::TarGz => extract_tar_gz(&archive_path, &target_dir),\n    })\n    .await??;\n\n    Ok(())\n}\n\n/// Extract a tar.gz archive\nfn extract_tar_gz(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> {\n    use flate2::read::GzDecoder;\n    use tar::Archive;\n\n    tracing::debug!(\"Extracting tar.gz: {archive_path:?} to {target_dir:?}\");\n\n    let file = File::open(archive_path)?;\n    let tar_stream = GzDecoder::new(file);\n    let mut archive = Archive::new(tar_stream);\n    archive.unpack(target_dir)?;\n\n    tracing::debug!(\"Extraction completed\");\n    Ok(())\n}\n\n/// Extract a zip archive\nfn extract_zip(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> {\n    tracing::debug!(\"Extracting zip: {archive_path:?} to {target_dir:?}\");\n\n    let file = File::open(archive_path)?;\n    let mut archive = zip::ZipArchive::new(file)\n        .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!(\"{e}\") })?;\n\n    archive\n        .extract(target_dir)\n        .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!(\"{e}\") })?;\n\n    tracing::debug!(\"Extraction completed\");\n    Ok(())\n}\n\n/// Move extracted directory to cache location with atomic operations and file-based locking\n///\n/// Uses a file-based lock to ensure atomicity when multiple processes/threads\n/// try to install the same runtime version concurrently.\npub async fn move_to_cache(\n    source: &AbsolutePath,\n    target: &AbsolutePathBuf,\n    version: &str,\n) -> Result<(), Error> {\n    // Create parent directory\n    let parent = target.parent().ok_or_else(|| Error::ExtractionFailed {\n        reason: \"Target path has no parent directory\".into(),\n    })?;\n    fs::create_dir_all(&parent).await?;\n\n    // Use a file-based lock to ensure atomicity of the move operation.\n    // This prevents race conditions when multiple processes/threads\n    // try to install the same runtime version concurrently.\n    let lock_path = parent.join(vite_str::format!(\"{version}.lock\"));\n    tracing::debug!(\"Acquiring lock file: {lock_path:?}\");\n\n    // Acquire file lock in a blocking task to avoid blocking the async runtime.\n    // The lock() call blocks until the lock is acquired.\n    let lock_path_clone = lock_path.clone();\n    // Store the lock file to keep it alive until end of function\n    let _lock_guard = tokio::task::spawn_blocking(move || {\n        let lock_file = File::create(lock_path_clone.as_path())?;\n        // Acquire exclusive lock (blocks until available)\n        lock_file.lock()?;\n        tracing::debug!(\"Lock acquired: {lock_path_clone:?}\");\n        Ok::<_, std::io::Error>(lock_file)\n    })\n    .await??;\n    tracing::debug!(\"Lock acquired: {lock_path:?}\");\n\n    // Check again after acquiring the lock, in case another process completed\n    // the installation while we were downloading\n    if fs::try_exists(target.as_path()).await.unwrap_or(false) {\n        tracing::debug!(\"Target already exists after lock acquisition, skipping move: {target:?}\");\n        // Lock is released when lock_file is dropped at end of scope\n        return Ok(());\n    }\n\n    // Atomic rename (lock is still held)\n    fs::rename(source.as_path(), target.as_path()).await?;\n    tracing::debug!(\"Atomic rename successful: {source:?} -> {target:?}\");\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_max_age() {\n        assert_eq!(parse_max_age(\"max-age=300\"), Some(300));\n        assert_eq!(parse_max_age(\"public, max-age=300\"), Some(300));\n        assert_eq!(parse_max_age(\"public, max-age=3600, immutable\"), Some(3600));\n        assert_eq!(parse_max_age(\"no-cache\"), None);\n        assert_eq!(parse_max_age(\"\"), None);\n        assert_eq!(parse_max_age(\"max-age=invalid\"), None);\n    }\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/error.rs",
    "content": "use thiserror::Error;\nuse vite_str::Str;\n\n/// Errors that can occur during JavaScript runtime management\n#[derive(Error, Debug)]\npub enum Error {\n    /// Version not found in official releases\n    #[error(\"Version {version} not found for {runtime}\")]\n    VersionNotFound { runtime: Str, version: Str },\n\n    /// Platform not supported for this runtime\n    #[error(\"Platform {platform} is not supported for {runtime}\")]\n    UnsupportedPlatform { platform: Str, runtime: Str },\n\n    /// Download failed after retries\n    #[error(\"Failed to download from {url}: {reason}\")]\n    DownloadFailed { url: Str, reason: Str },\n\n    /// Hash verification failed (download corrupted)\n    #[error(\"Hash mismatch for {filename}: expected {expected}, got {actual}\")]\n    HashMismatch { filename: Str, expected: Str, actual: Str },\n\n    /// Archive extraction failed\n    #[error(\"Failed to extract archive: {reason}\")]\n    ExtractionFailed { reason: Str },\n\n    /// SHASUMS file parsing failed\n    #[error(\"Failed to parse SHASUMS256.txt: {reason}\")]\n    ShasumsParseFailed { reason: Str },\n\n    /// Hash not found in SHASUMS file\n    #[error(\"Hash not found for {filename} in SHASUMS256.txt\")]\n    HashNotFound { filename: Str },\n\n    /// Failed to parse version index\n    #[error(\"Failed to parse version index: {reason}\")]\n    VersionIndexParseFailed { reason: Str },\n\n    /// No version matching the requirement found\n    #[error(\"No version matching '{version_req}' found\")]\n    NoMatchingVersion { version_req: Str },\n\n    /// Invalid LTS alias format\n    #[error(\"Invalid LTS alias format: '{alias}'\")]\n    InvalidLtsAlias { alias: Str },\n\n    /// Unknown LTS codename\n    #[error(\n        \"Unknown LTS codename: '{codename}'. Valid codenames include: hydrogen (18.x), iron (20.x), jod (22.x)\"\n    )]\n    UnknownLtsCodename { codename: Str },\n\n    /// Invalid LTS offset (too large)\n    #[error(\"Invalid LTS offset: {offset}. Only {available} LTS lines are available\")]\n    InvalidLtsOffset { offset: i32, available: usize },\n\n    /// IO error\n    #[error(transparent)]\n    Io(#[from] std::io::Error),\n\n    /// HTTP request error\n    #[error(transparent)]\n    Reqwest(#[from] reqwest::Error),\n\n    /// Join error from tokio\n    #[error(transparent)]\n    JoinError(#[from] tokio::task::JoinError),\n\n    /// JSON parsing error\n    #[error(transparent)]\n    Json(#[from] serde_json::Error),\n\n    /// Semver range parsing error\n    #[error(transparent)]\n    SemverRange(#[from] node_semver::SemverError),\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/lib.rs",
    "content": "//! JavaScript Runtime Management Library\n//!\n//! This crate provides functionality to download and cache JavaScript runtimes\n//! like Node.js. It supports automatic platform detection, integrity verification\n//! via SHASUMS256.txt, and atomic operations for concurrent-safe caching.\n//!\n//! # Example\n//!\n//! ```rust,ignore\n//! use vite_js_runtime::{JsRuntimeType, download_runtime};\n//!\n//! let runtime = download_runtime(JsRuntimeType::Node, \"22.13.1\").await?;\n//! println!(\"Node.js installed at: {}\", runtime.get_binary_path());\n//! ```\n//!\n//! # Project-Based Runtime Download\n//!\n//! You can also download a runtime based on a project's `devEngines.runtime` configuration:\n//!\n//! ```rust,ignore\n//! use vite_js_runtime::download_runtime_for_project;\n//! use vite_path::AbsolutePathBuf;\n//!\n//! let project_path = AbsolutePathBuf::new(\"/path/to/project\".into()).unwrap();\n//! let runtime = download_runtime_for_project(&project_path).await?;\n//! ```\n//!\n//! # Adding a New Runtime\n//!\n//! To add support for a new JavaScript runtime (e.g., Bun, Deno):\n//!\n//! 1. Create a new provider in `src/providers/` implementing `JsRuntimeProvider`\n//! 2. Add the runtime type to `JsRuntimeType` enum\n//! 3. Add a match arm in `download_runtime()` to use the new provider\n\nmod cache;\nmod dev_engines;\nmod download;\nmod error;\nmod platform;\nmod provider;\nmod providers;\nmod runtime;\n\npub use dev_engines::{\n    parse_node_version_content, read_node_version_file, write_node_version_file,\n};\npub use error::Error;\npub use platform::{Arch, Os, Platform};\npub use provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider};\npub use providers::{LtsInfo, NodeProvider, NodeVersionEntry};\npub use runtime::{\n    JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime,\n    download_runtime_for_project, download_runtime_with_provider, is_valid_version,\n    normalize_version, read_package_json, resolve_node_version,\n};\n"
  },
  {
    "path": "crates/vite_js_runtime/src/platform.rs",
    "content": "use std::fmt;\n\n/// Represents a platform (OS + architecture) combination\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub struct Platform {\n    pub os: Os,\n    pub arch: Arch,\n}\n\n/// Operating system\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Os {\n    Linux,\n    Darwin,\n    Windows,\n}\n\n/// CPU architecture\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum Arch {\n    X64,\n    Arm64,\n}\n\nimpl Platform {\n    /// Detect the current platform\n    #[must_use]\n    pub const fn current() -> Self {\n        Self { os: Os::current(), arch: Arch::current() }\n    }\n}\n\nimpl fmt::Display for Platform {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        write!(f, \"{}-{}\", self.os, self.arch)\n    }\n}\n\nimpl Os {\n    /// Detect the current operating system.\n    ///\n    /// # Supported platforms\n    /// - Linux (`target_os = \"linux\"`)\n    /// - macOS (`target_os = \"macos\"`)\n    /// - Windows (`target_os = \"windows\"`)\n    ///\n    /// Compilation will fail on unsupported operating systems.\n    #[must_use]\n    pub const fn current() -> Self {\n        #[cfg(target_os = \"linux\")]\n        {\n            Self::Linux\n        }\n        #[cfg(target_os = \"macos\")]\n        {\n            Self::Darwin\n        }\n        #[cfg(target_os = \"windows\")]\n        {\n            Self::Windows\n        }\n        #[cfg(not(any(target_os = \"linux\", target_os = \"macos\", target_os = \"windows\")))]\n        {\n            compile_error!(\n                \"Unsupported operating system. vite_js_runtime only supports Linux, macOS, and Windows.\"\n            )\n        }\n    }\n}\n\nimpl fmt::Display for Os {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::Linux => write!(f, \"linux\"),\n            Self::Darwin => write!(f, \"darwin\"),\n            Self::Windows => write!(f, \"windows\"),\n        }\n    }\n}\n\nimpl Arch {\n    /// Detect the current CPU architecture.\n    ///\n    /// # Supported architectures\n    /// - x86_64 (`target_arch = \"x86_64\"`)\n    /// - ARM64/AArch64 (`target_arch = \"aarch64\"`)\n    ///\n    /// Compilation will fail on unsupported architectures.\n    #[must_use]\n    pub const fn current() -> Self {\n        #[cfg(target_arch = \"x86_64\")]\n        {\n            Self::X64\n        }\n        #[cfg(target_arch = \"aarch64\")]\n        {\n            Self::Arm64\n        }\n        #[cfg(not(any(target_arch = \"x86_64\", target_arch = \"aarch64\")))]\n        {\n            compile_error!(\n                \"Unsupported CPU architecture. vite_js_runtime only supports x86_64 and aarch64.\"\n            )\n        }\n    }\n}\n\nimpl fmt::Display for Arch {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Self::X64 => write!(f, \"x64\"),\n            Self::Arm64 => write!(f, \"arm64\"),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_platform_detection() {\n        let platform = Platform::current();\n\n        // Just verify it doesn't panic and returns a valid platform\n        let platform_str = platform.to_string();\n        assert!(!platform_str.is_empty());\n\n        // Verify format is \"os-arch\"\n        let parts: Vec<&str> = platform_str.split('-').collect();\n        assert_eq!(parts.len(), 2);\n    }\n\n    #[test]\n    fn test_platform_display() {\n        let cases = [\n            (Platform { os: Os::Linux, arch: Arch::X64 }, \"linux-x64\"),\n            (Platform { os: Os::Linux, arch: Arch::Arm64 }, \"linux-arm64\"),\n            (Platform { os: Os::Darwin, arch: Arch::X64 }, \"darwin-x64\"),\n            (Platform { os: Os::Darwin, arch: Arch::Arm64 }, \"darwin-arm64\"),\n            (Platform { os: Os::Windows, arch: Arch::X64 }, \"windows-x64\"),\n            (Platform { os: Os::Windows, arch: Arch::Arm64 }, \"windows-arm64\"),\n        ];\n\n        for (platform, expected) in cases {\n            assert_eq!(platform.to_string(), expected);\n        }\n    }\n\n    #[test]\n    fn test_os_display() {\n        assert_eq!(Os::Linux.to_string(), \"linux\");\n        assert_eq!(Os::Darwin.to_string(), \"darwin\");\n        assert_eq!(Os::Windows.to_string(), \"windows\");\n    }\n\n    #[test]\n    fn test_arch_display() {\n        assert_eq!(Arch::X64.to_string(), \"x64\");\n        assert_eq!(Arch::Arm64.to_string(), \"arm64\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/provider.rs",
    "content": "//! JavaScript runtime provider trait and supporting types.\n//!\n//! This module defines the trait that all runtime providers (Node, Bun, Deno)\n//! must implement, along with types for describing download information.\n\nuse async_trait::async_trait;\nuse vite_str::Str;\n\nuse crate::{Error, Platform};\n\n/// Archive format for runtime distributions\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum ArchiveFormat {\n    /// Gzip-compressed tar archive (.tar.gz)\n    TarGz,\n    /// ZIP archive (.zip)\n    Zip,\n}\n\nimpl ArchiveFormat {\n    /// Get the file extension for this archive format\n    #[must_use]\n    pub const fn extension(self) -> &'static str {\n        match self {\n            Self::TarGz => \"tar.gz\",\n            Self::Zip => \"zip\",\n        }\n    }\n}\n\n/// How to verify the integrity of a downloaded archive\n#[derive(Debug, Clone)]\npub enum HashVerification {\n    /// Download a SHASUMS file and parse it to find the hash\n    /// Used by Node.js (SHASUMS256.txt format)\n    ShasumsFile {\n        /// URL to the SHASUMS file\n        url: Str,\n    },\n    /// No hash verification (not recommended, but some runtimes may not provide checksums)\n    None,\n}\n\n/// Information needed to download a runtime\n#[derive(Debug, Clone)]\npub struct DownloadInfo {\n    /// URL to download the archive from\n    pub archive_url: Str,\n    /// Filename of the archive\n    pub archive_filename: Str,\n    /// Format of the archive\n    pub archive_format: ArchiveFormat,\n    /// How to verify the download integrity\n    pub hash_verification: HashVerification,\n    /// Name of the directory inside the archive after extraction\n    pub extracted_dir_name: Str,\n}\n\n/// Trait for JavaScript runtime providers\n///\n/// Each runtime (Node.js, Bun, Deno) implements this trait to provide\n/// runtime-specific logic for downloading and installing.\n#[async_trait]\npub trait JsRuntimeProvider: Send + Sync {\n    /// Get the name of this runtime (e.g., \"node\", \"bun\", \"deno\")\n    fn name(&self) -> &'static str;\n\n    /// Get the platform string used in download URLs for this runtime\n    /// e.g., \"linux-x64\", \"darwin-arm64\", \"win-x64\"\n    fn platform_string(&self, platform: Platform) -> Str;\n\n    /// Get download information for a specific version and platform\n    fn get_download_info(&self, version: &str, platform: Platform) -> DownloadInfo;\n\n    /// Get the relative path to the runtime binary from the install directory\n    /// e.g., \"bin/node\" on Unix, \"node.exe\" on Windows\n    fn binary_relative_path(&self, platform: Platform) -> Str;\n\n    /// Get the relative path to the bin directory from the install directory\n    /// e.g., \"bin\" on Unix, \"\" (empty) on Windows\n    fn bin_dir_relative_path(&self, platform: Platform) -> Str;\n\n    /// Parse a SHASUMS file to extract the hash for a specific filename\n    /// Different runtimes may have different SHASUMS formats\n    ///\n    /// # Errors\n    ///\n    /// Returns an error if the filename is not found in the SHASUMS content.\n    fn parse_shasums(&self, shasums_content: &str, filename: &str) -> Result<Str, Error>;\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/providers/mod.rs",
    "content": "//! JavaScript runtime provider implementations.\n//!\n//! This module contains implementations of the `JsRuntimeProvider` trait\n//! for each supported JavaScript runtime.\n\nmod node;\n\npub use node::{LtsInfo, NodeProvider, NodeVersionEntry};\n"
  },
  {
    "path": "crates/vite_js_runtime/src/providers/node.rs",
    "content": "//! Node.js runtime provider implementation.\n\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse async_trait::async_trait;\nuse node_semver::{Range, Version};\nuse serde::{Deserialize, Serialize};\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_str::Str;\n\nuse crate::{\n    Error, Platform,\n    download::fetch_with_cache_headers,\n    platform::Os,\n    provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider},\n};\n\n/// Default Node.js distribution base URL\nconst DEFAULT_NODE_DIST_URL: &str = \"https://nodejs.org/dist\";\n\n/// Environment variable to override the Node.js distribution URL\n\n/// Default cache TTL in seconds (1 hour)\nconst DEFAULT_CACHE_TTL_SECS: u64 = 3600;\n\n/// A single entry from the Node.js version index\n#[derive(Deserialize, Serialize, Debug, Clone)]\npub struct NodeVersionEntry {\n    /// Version string (e.g., \"v25.5.0\")\n    pub version: Str,\n    /// LTS information\n    #[serde(default)]\n    pub lts: LtsInfo,\n}\n\nimpl NodeVersionEntry {\n    /// Check if this version is an LTS release.\n    #[must_use]\n    pub fn is_lts(&self) -> bool {\n        matches!(self.lts, LtsInfo::Codename(_))\n    }\n}\n\n/// LTS field can be false or a codename string\n#[derive(Deserialize, Serialize, Debug, Clone, Default)]\n#[serde(untagged)]\npub enum LtsInfo {\n    /// Not an LTS release\n    #[default]\n    NotLts,\n    /// Boolean false (not LTS)\n    Boolean(bool),\n    /// LTS codename (e.g., \"Jod\")\n    Codename(Str),\n}\n\n/// Cached version index with expiration\n#[derive(Deserialize, Serialize, Debug)]\nstruct VersionIndexCache {\n    /// Unix timestamp when cache expires\n    expires_at: u64,\n    /// ETag from HTTP response (for conditional requests)\n    #[serde(default)]\n    etag: Option<Str>,\n    /// Cached version entries\n    versions: Vec<NodeVersionEntry>,\n}\n\n/// Node.js runtime provider\n#[derive(Debug, Default)]\npub struct NodeProvider;\n\nimpl NodeProvider {\n    /// Create a new `NodeProvider`\n    #[must_use]\n    pub const fn new() -> Self {\n        Self\n    }\n\n    /// Check if a version string is an exact version (not a range).\n    ///\n    /// Returns `true` for exact versions like \"20.18.0\", \"22.13.1\".\n    /// Returns `false` for ranges like \"^20.18.0\", \"~20.18.0\", \">=20 <22\", \"20.x\".\n    #[must_use]\n    pub fn is_exact_version(version_str: &str) -> bool {\n        Version::parse(version_str).is_ok()\n    }\n\n    /// Find a locally cached version that satisfies the version requirement.\n    ///\n    /// This checks the local cache directory for installed Node.js versions\n    /// and returns a version that satisfies the semver range. Prefers LTS\n    /// versions over non-LTS versions.\n    ///\n    /// # Arguments\n    /// * `version_req` - A semver range requirement (e.g., \"^20.18.0\")\n    /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite-plus/js_runtime`)\n    ///\n    /// # Returns\n    /// The highest LTS cached version that satisfies the requirement, or the\n    /// highest non-LTS version if no LTS version matches, or `None` if no\n    /// cached version matches.\n    ///\n    /// # Errors\n    /// Returns an error if the version requirement is invalid.\n    pub async fn find_cached_version(\n        &self,\n        version_req: &str,\n        cache_dir: &AbsolutePath,\n    ) -> Result<Option<Str>, Error> {\n        let node_cache = cache_dir.join(\"node\");\n\n        // List directories in cache\n        let mut entries = match tokio::fs::read_dir(&node_cache).await {\n            Ok(entries) => entries,\n            Err(_) => return Ok(None), // Cache dir doesn't exist\n        };\n\n        let range = Range::parse(version_req)?;\n        let mut matching_versions: Vec<Version> = Vec::new();\n        let platform = Platform::current();\n\n        while let Some(entry) = entries.next_entry().await? {\n            let name = entry.file_name().to_string_lossy().to_string();\n            // Skip non-version entries (index_cache.json, .lock files)\n            if let Ok(version) = Version::parse(&name) {\n                // Check if binary exists (valid installation)\n                let binary_path = node_cache.join(&name).join(self.binary_relative_path(platform));\n                if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) {\n                    if range.satisfies(&version) {\n                        matching_versions.push(version);\n                    }\n                }\n            }\n        }\n\n        if matching_versions.is_empty() {\n            return Ok(None);\n        }\n\n        // Fetch version index to check LTS status\n        let version_index = self.fetch_version_index().await?;\n\n        // Build a set of LTS versions for fast lookup\n        let lts_versions: std::collections::HashSet<String> = version_index\n            .iter()\n            .filter(|e| e.is_lts())\n            .map(|e| e.version.strip_prefix('v').unwrap_or(&e.version).to_string())\n            .collect();\n\n        // Prefer LTS: find highest LTS cached version first\n        let lts_max =\n            matching_versions.iter().filter(|v| lts_versions.contains(&v.to_string())).max();\n\n        if let Some(version) = lts_max {\n            return Ok(Some(version.to_string().into()));\n        }\n\n        // Fallback to highest non-LTS\n        Ok(matching_versions.into_iter().max().map(|v| v.to_string().into()))\n    }\n\n    /// Get the archive format for a platform\n    const fn archive_format(platform: Platform) -> ArchiveFormat {\n        match platform.os {\n            Os::Windows => ArchiveFormat::Zip,\n            Os::Linux | Os::Darwin => ArchiveFormat::TarGz,\n        }\n    }\n\n    /// Fetch the version index from nodejs.org/dist/index.json with HTTP caching.\n    ///\n    /// Uses ETag-based conditional requests to minimize bandwidth when cache expires.\n    /// If a network error occurs and a local cache exists (even if expired), returns\n    /// the cached version with a warning log instead of failing.\n    ///\n    /// # Errors\n    ///\n    /// Returns an error only if the download fails and no local cache exists.\n    pub async fn fetch_version_index(&self) -> Result<Vec<NodeVersionEntry>, Error> {\n        let cache_dir = crate::cache::get_cache_dir()?;\n        let cache_path = cache_dir.join(\"node/index_cache.json\");\n\n        // Try to load from cache\n        let Some(cache) = load_cache(&cache_path).await else {\n            // No cache - must fetch\n            return self.fetch_and_cache(&cache_path).await;\n        };\n\n        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();\n\n        // If cache is still fresh, use it\n        if now < cache.expires_at {\n            tracing::debug!(\"Using cached version index (expires in {}s)\", cache.expires_at - now);\n            return Ok(cache.versions);\n        }\n\n        // Cache expired - try conditional request with ETag if available\n        if let Some(ref etag) = cache.etag {\n            tracing::debug!(\"Cache expired, trying conditional request with ETag\");\n            match self.fetch_with_etag(etag, &cache, &cache_path).await {\n                Ok(versions) => return Ok(versions),\n                Err(e) => {\n                    // Network error with ETag request - return cached version\n                    tracing::warn!(\"Conditional request failed: {e}, using expired cache\");\n                    return Ok(cache.versions);\n                }\n            }\n        }\n\n        // No ETag - try full fetch, fallback to cache\n        tracing::debug!(\"Cache expired, no ETag available for conditional request\");\n        match self.fetch_and_cache(&cache_path).await {\n            Ok(versions) => Ok(versions),\n            Err(e) => {\n                tracing::warn!(\"Failed to fetch version index: {e}, using expired cache\");\n                Ok(cache.versions)\n            }\n        }\n    }\n\n    /// Try conditional fetch with ETag, returns cached versions if 304\n    async fn fetch_with_etag(\n        &self,\n        etag: &str,\n        cache: &VersionIndexCache,\n        cache_path: &AbsolutePathBuf,\n    ) -> Result<Vec<NodeVersionEntry>, Error> {\n        let base_url = get_dist_url();\n        let index_url = vite_str::format!(\"{base_url}/index.json\");\n\n        let response = fetch_with_cache_headers(&index_url, Some(etag)).await?;\n\n        if response.not_modified {\n            // Server confirmed data hasn't changed, refresh TTL\n            tracing::debug!(\"Server returned 304 Not Modified, refreshing cache TTL\");\n            let new_cache = VersionIndexCache {\n                expires_at: calculate_expires_at(response.max_age),\n                etag: cache.etag.clone(),\n                versions: cache.versions.clone(),\n            };\n            save_cache(cache_path, &new_cache).await;\n            return Ok(cache.versions.clone());\n        }\n\n        // Got new data\n        let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed {\n            reason: \"Empty response body\".into(),\n        })?;\n        let versions: Vec<NodeVersionEntry> = serde_json::from_str(&body)?;\n\n        let new_cache = VersionIndexCache {\n            expires_at: calculate_expires_at(response.max_age),\n            etag: response.etag,\n            versions: versions.clone(),\n        };\n        save_cache(cache_path, &new_cache).await;\n\n        Ok(versions)\n    }\n\n    /// Fetch the version index and cache it.\n    async fn fetch_and_cache(\n        &self,\n        cache_path: &AbsolutePathBuf,\n    ) -> Result<Vec<NodeVersionEntry>, Error> {\n        let base_url = get_dist_url();\n        let index_url = vite_str::format!(\"{base_url}/index.json\");\n\n        tracing::debug!(\"Fetching version index from {index_url}\");\n        let response = fetch_with_cache_headers(&index_url, None).await?;\n\n        let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed {\n            reason: \"Empty response body\".into(),\n        })?;\n        let versions: Vec<NodeVersionEntry> = serde_json::from_str(&body)?;\n\n        let cache = VersionIndexCache {\n            expires_at: calculate_expires_at(response.max_age),\n            etag: response.etag,\n            versions: versions.clone(),\n        };\n        save_cache(cache_path, &cache).await;\n\n        Ok(versions)\n    }\n\n    /// Resolve a version requirement (e.g., \"^24.4.0\") to an exact version.\n    ///\n    /// Returns the highest version that satisfies the semver range.\n    /// Uses npm-compatible semver range parsing.\n    ///\n    /// # Errors\n    ///\n    /// Returns an error if no matching version is found or if the version requirement is invalid.\n    pub async fn resolve_version(&self, version_req: &str) -> Result<Str, Error> {\n        let versions = self.fetch_version_index().await?;\n        resolve_version_from_list(version_req, &versions)\n    }\n\n    /// Get the latest LTS version with the highest version number.\n    ///\n    /// # Errors\n    ///\n    /// Returns an error if no LTS version is found or the version index cannot be fetched.\n    pub async fn resolve_latest_version(&self) -> Result<Str, Error> {\n        let versions = self.fetch_version_index().await?;\n        find_latest_lts_version(&versions)\n    }\n\n    /// Check if a version string is an LTS alias (e.g., `lts/*`, `lts/iron`, `lts/-1`).\n    ///\n    /// Returns `true` for LTS alias formats:\n    /// - `lts/*` - Latest LTS version\n    /// - `lts/<codename>` - Specific LTS line (e.g., `lts/iron`, `lts/jod`)\n    /// - `lts/-n` - Nth-highest LTS line (e.g., `lts/-1` for second highest)\n    #[must_use]\n    pub fn is_lts_alias(version: &str) -> bool {\n        version.starts_with(\"lts/\")\n    }\n\n    /// Check if a version string is a \"latest\" alias.\n    ///\n    /// Returns `true` for:\n    /// - `latest` - The absolute latest Node.js version (including non-LTS)\n    #[must_use]\n    pub fn is_latest_alias(version: &str) -> bool {\n        version.eq_ignore_ascii_case(\"latest\")\n    }\n\n    /// Check if a version string is any kind of alias (lts/* or latest).\n    #[must_use]\n    pub fn is_version_alias(version: &str) -> bool {\n        Self::is_lts_alias(version) || Self::is_latest_alias(version)\n    }\n\n    /// Resolve an LTS alias to an exact version.\n    ///\n    /// # Supported Formats\n    ///\n    /// - `lts/*` - Returns the latest LTS version\n    /// - `lts/<codename>` - Returns the highest version for that LTS line (e.g., `lts/iron` → 20.x)\n    /// - `lts/-n` - Returns the nth-highest LTS line (e.g., `lts/-1` → second highest)\n    ///\n    /// # Errors\n    ///\n    /// Returns an error if:\n    /// - The alias format is invalid\n    /// - The codename is not recognized\n    /// - The offset is too large (not enough LTS lines)\n    pub async fn resolve_lts_alias(&self, alias: &str) -> Result<Str, Error> {\n        let suffix = alias\n            .strip_prefix(\"lts/\")\n            .ok_or_else(|| Error::InvalidLtsAlias { alias: alias.into() })?;\n\n        // lts/* - latest LTS\n        if suffix == \"*\" {\n            return self.resolve_latest_version().await;\n        }\n\n        // lts/-n - nth-highest LTS (e.g., lts/-1 = second highest)\n        if suffix.starts_with('-') {\n            if let Ok(n) = suffix.parse::<i32>() {\n                if n < 0 {\n                    return self.resolve_lts_by_offset(n).await;\n                }\n            }\n        }\n\n        // lts/<codename> - specific LTS line\n        self.resolve_lts_by_codename(suffix).await\n    }\n\n    /// Resolve LTS by codename (e.g., \"iron\" → 20.x, \"jod\" → 22.x).\n    async fn resolve_lts_by_codename(&self, codename: &str) -> Result<Str, Error> {\n        let versions = self.fetch_version_index().await?;\n        let target = codename.to_lowercase();\n\n        // Find all versions matching the codename\n        let matching: Vec<_> = versions\n            .iter()\n            .filter(|v| matches!(&v.lts, LtsInfo::Codename(name) if name.to_lowercase() == target))\n            .collect();\n\n        if matching.is_empty() {\n            return Err(Error::UnknownLtsCodename { codename: codename.into() });\n        }\n\n        // Find the highest matching version\n        let highest = matching\n            .into_iter()\n            .filter_map(|entry| {\n                let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version);\n                Version::parse(version_str).ok().map(|v| (v, version_str))\n            })\n            .max_by(|(a, _), (b, _)| a.cmp(b));\n\n        highest\n            .map(|(_, version_str)| version_str.into())\n            .ok_or_else(|| Error::UnknownLtsCodename { codename: codename.into() })\n    }\n\n    /// Resolve LTS by offset (e.g., -1 = second highest LTS line).\n    ///\n    /// The offset is negative: lts/-1 means \"one below the latest LTS line\".\n    async fn resolve_lts_by_offset(&self, offset: i32) -> Result<Str, Error> {\n        let versions = self.fetch_version_index().await?;\n\n        // Get unique LTS codenames ordered by highest version in each line\n        let mut lts_lines: Vec<(String, u64)> = Vec::new();\n\n        for entry in &versions {\n            if let LtsInfo::Codename(name) = &entry.lts {\n                let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version);\n                if let Ok(ver) = Version::parse(version_str) {\n                    let key = name.to_lowercase();\n                    // Only add if we haven't seen this codename yet (keeping highest version)\n                    if !lts_lines.iter().any(|(n, _)| n == &key) {\n                        lts_lines.push((key, ver.major));\n                    }\n                }\n            }\n        }\n\n        // Sort by major version descending (highest first)\n        lts_lines.sort_by(|a, b| b.1.cmp(&a.1));\n\n        // offset is negative, so lts/-1 = index 1 (second highest)\n        let index = (-offset) as usize;\n\n        let (codename, _) = lts_lines\n            .get(index)\n            .ok_or_else(|| Error::InvalidLtsOffset { offset, available: lts_lines.len() })?;\n\n        self.resolve_lts_by_codename(codename).await\n    }\n}\n\n/// Find the LTS version with the highest version number from a list of versions.\n///\n/// # Errors\n///\n/// Returns an error if no LTS version is found in the list.\nfn find_latest_lts_version(versions: &[NodeVersionEntry]) -> Result<Str, Error> {\n    let latest_lts = versions\n        .iter()\n        .filter(|entry| entry.is_lts())\n        .filter_map(|entry| {\n            let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version);\n            Version::parse(version_str).ok().map(|v| (v, version_str))\n        })\n        .max_by(|(a, _), (b, _)| a.cmp(b));\n\n    latest_lts.map(|(_, version_str)| version_str.into()).ok_or_else(|| {\n        Error::VersionIndexParseFailed { reason: \"No LTS version found in version index\".into() }\n    })\n}\n\n/// Resolve a version requirement to a matching version from a list.\n///\n/// Prefers LTS versions over non-LTS versions. Returns the highest LTS version\n/// that satisfies the range, or falls back to the highest non-LTS version if\n/// no LTS version matches.\n///\n/// # Errors\n///\n/// Returns an error if no matching version is found or if the version requirement is invalid.\nfn resolve_version_from_list(\n    version_req: &str,\n    versions: &[NodeVersionEntry],\n) -> Result<Str, Error> {\n    let range = Range::parse(version_req)?;\n\n    // Collect all matching versions with their LTS status\n    let matching_versions: Vec<(Version, &str, bool)> = versions\n        .iter()\n        .filter_map(|entry| {\n            let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version);\n            Version::parse(version_str)\n                .ok()\n                .filter(|v| range.satisfies(v))\n                .map(|v| (v, version_str, entry.is_lts()))\n        })\n        .collect();\n\n    // Prefer LTS versions: find highest LTS first\n    let lts_max = matching_versions\n        .iter()\n        .filter(|(_, _, is_lts)| *is_lts)\n        .max_by(|(a, _, _), (b, _, _)| a.cmp(b));\n\n    if let Some((_, version_str, _)) = lts_max {\n        return Ok((*version_str).into());\n    }\n\n    // Fallback to highest non-LTS version\n    matching_versions\n        .into_iter()\n        .max_by(|(a, _, _), (b, _, _)| a.cmp(b))\n        .map(|(_, version_str, _)| version_str.into())\n        .ok_or_else(|| Error::NoMatchingVersion { version_req: version_req.into() })\n}\n\n/// Load cache from file.\nasync fn load_cache(cache_path: &AbsolutePathBuf) -> Option<VersionIndexCache> {\n    let content = tokio::fs::read_to_string(cache_path).await.ok()?;\n    serde_json::from_str(&content).ok()\n}\n\n/// Save cache to file.\nasync fn save_cache(cache_path: &AbsolutePathBuf, cache: &VersionIndexCache) {\n    // Ensure cache directory exists\n    if let Some(parent) = cache_path.parent() {\n        tokio::fs::create_dir_all(parent).await.ok();\n    }\n\n    // Write cache file (ignore errors)\n    if let Ok(cache_json) = serde_json::to_string(cache) {\n        tokio::fs::write(cache_path, cache_json).await.ok();\n    }\n}\n\n/// Calculate expiration timestamp from max_age or default TTL.\nfn calculate_expires_at(max_age: Option<u64>) -> u64 {\n    let ttl = max_age.unwrap_or(DEFAULT_CACHE_TTL_SECS);\n    SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + ttl\n}\n\n/// Get the Node.js distribution base URL\n///\n/// Returns the value of `VITE_NODE_DIST_MIRROR` environment variable if set,\n/// otherwise returns the default `https://nodejs.org/dist`.\nfn get_dist_url() -> Str {\n    vite_shared::EnvConfig::get().node_dist_mirror.map_or_else(\n        || DEFAULT_NODE_DIST_URL.into(),\n        |url| Str::from(url.trim_end_matches('/').to_string()),\n    )\n}\n\n#[async_trait]\nimpl JsRuntimeProvider for NodeProvider {\n    fn name(&self) -> &'static str {\n        \"node\"\n    }\n\n    fn platform_string(&self, platform: Platform) -> Str {\n        let os = match platform.os {\n            Os::Linux => \"linux\",\n            Os::Darwin => \"darwin\",\n            Os::Windows => \"win\",\n        };\n        let arch = match platform.arch {\n            crate::platform::Arch::X64 => \"x64\",\n            crate::platform::Arch::Arm64 => \"arm64\",\n        };\n        vite_str::format!(\"{os}-{arch}\")\n    }\n\n    fn get_download_info(&self, version: &str, platform: Platform) -> DownloadInfo {\n        let base_url = get_dist_url();\n        let platform_str = self.platform_string(platform);\n        let format = Self::archive_format(platform);\n        let ext = format.extension();\n\n        let archive_filename: Str = vite_str::format!(\"node-v{version}-{platform_str}.{ext}\");\n        let archive_url = vite_str::format!(\"{base_url}/v{version}/{archive_filename}\");\n        let shasums_url = vite_str::format!(\"{base_url}/v{version}/SHASUMS256.txt\");\n        let extracted_dir_name = vite_str::format!(\"node-v{version}-{platform_str}\");\n\n        DownloadInfo {\n            archive_url,\n            archive_filename,\n            archive_format: format,\n            hash_verification: HashVerification::ShasumsFile { url: shasums_url },\n            extracted_dir_name,\n        }\n    }\n\n    fn binary_relative_path(&self, platform: Platform) -> Str {\n        match platform.os {\n            Os::Windows => \"node.exe\".into(),\n            Os::Linux | Os::Darwin => \"bin/node\".into(),\n        }\n    }\n\n    fn bin_dir_relative_path(&self, platform: Platform) -> Str {\n        match platform.os {\n            Os::Windows => \"\".into(),\n            Os::Linux | Os::Darwin => \"bin\".into(),\n        }\n    }\n\n    fn parse_shasums(&self, shasums_content: &str, filename: &str) -> Result<Str, Error> {\n        // Node.js SHASUMS256.txt format: \"<hash>  <filename>\" (two spaces between)\n        for line in shasums_content.lines() {\n            let parts: Vec<&str> = line.splitn(2, \"  \").collect();\n            if parts.len() == 2 {\n                let hash = parts[0].trim();\n                let file = parts[1].trim();\n                if file == filename {\n                    return Ok(hash.into());\n                }\n            }\n        }\n\n        Err(Error::HashNotFound { filename: filename.into() })\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::platform::{Arch, Os};\n\n    #[test]\n    fn test_platform_string() {\n        let provider = NodeProvider::new();\n\n        let cases = [\n            (Platform { os: Os::Linux, arch: Arch::X64 }, \"linux-x64\"),\n            (Platform { os: Os::Linux, arch: Arch::Arm64 }, \"linux-arm64\"),\n            (Platform { os: Os::Darwin, arch: Arch::X64 }, \"darwin-x64\"),\n            (Platform { os: Os::Darwin, arch: Arch::Arm64 }, \"darwin-arm64\"),\n            (Platform { os: Os::Windows, arch: Arch::X64 }, \"win-x64\"),\n            (Platform { os: Os::Windows, arch: Arch::Arm64 }, \"win-arm64\"),\n        ];\n\n        for (platform, expected) in cases {\n            assert_eq!(provider.platform_string(platform), expected);\n        }\n    }\n\n    #[test]\n    fn test_get_download_info() {\n        let provider = NodeProvider::new();\n        let platform = Platform { os: Os::Linux, arch: Arch::X64 };\n\n        let info = provider.get_download_info(\"22.13.1\", platform);\n\n        assert_eq!(info.archive_filename, \"node-v22.13.1-linux-x64.tar.gz\");\n        assert_eq!(\n            info.archive_url,\n            \"https://nodejs.org/dist/v22.13.1/node-v22.13.1-linux-x64.tar.gz\"\n        );\n        assert_eq!(info.archive_format, ArchiveFormat::TarGz);\n        assert_eq!(info.extracted_dir_name, \"node-v22.13.1-linux-x64\");\n\n        if let HashVerification::ShasumsFile { url } = &info.hash_verification {\n            assert_eq!(url, \"https://nodejs.org/dist/v22.13.1/SHASUMS256.txt\");\n        } else {\n            panic!(\"Expected ShasumsFile verification\");\n        }\n    }\n\n    #[test]\n    fn test_get_download_info_windows() {\n        let provider = NodeProvider::new();\n        let platform = Platform { os: Os::Windows, arch: Arch::X64 };\n\n        let info = provider.get_download_info(\"22.13.1\", platform);\n\n        assert_eq!(info.archive_filename, \"node-v22.13.1-win-x64.zip\");\n        assert_eq!(info.archive_format, ArchiveFormat::Zip);\n    }\n\n    #[test]\n    fn test_binary_relative_path() {\n        let provider = NodeProvider::new();\n\n        assert_eq!(\n            provider.binary_relative_path(Platform { os: Os::Linux, arch: Arch::X64 }),\n            \"bin/node\"\n        );\n        assert_eq!(\n            provider.binary_relative_path(Platform { os: Os::Darwin, arch: Arch::Arm64 }),\n            \"bin/node\"\n        );\n        assert_eq!(\n            provider.binary_relative_path(Platform { os: Os::Windows, arch: Arch::X64 }),\n            \"node.exe\"\n        );\n    }\n\n    #[test]\n    fn test_bin_dir_relative_path() {\n        let provider = NodeProvider::new();\n\n        assert_eq!(\n            provider.bin_dir_relative_path(Platform { os: Os::Linux, arch: Arch::X64 }),\n            \"bin\"\n        );\n        assert_eq!(\n            provider.bin_dir_relative_path(Platform { os: Os::Windows, arch: Arch::X64 }),\n            \"\"\n        );\n    }\n\n    #[test]\n    fn test_parse_shasums() {\n        let provider = NodeProvider::new();\n\n        let content = r\"abc123def456  node-v22.13.1-linux-x64.tar.gz\n789xyz000111  node-v22.13.1-darwin-arm64.tar.gz\nfedcba987654  node-v22.13.1-win-x64.zip\";\n\n        assert_eq!(\n            provider.parse_shasums(content, \"node-v22.13.1-linux-x64.tar.gz\").unwrap(),\n            \"abc123def456\"\n        );\n        assert_eq!(\n            provider.parse_shasums(content, \"node-v22.13.1-darwin-arm64.tar.gz\").unwrap(),\n            \"789xyz000111\"\n        );\n        assert_eq!(\n            provider.parse_shasums(content, \"node-v22.13.1-win-x64.zip\").unwrap(),\n            \"fedcba987654\"\n        );\n\n        // Test missing filename\n        let result = provider.parse_shasums(content, \"nonexistent.tar.gz\");\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_get_dist_url_default() {\n        vite_shared::EnvConfig::test_scope(vite_shared::EnvConfig::for_test(), || {\n            assert_eq!(get_dist_url(), \"https://nodejs.org/dist\");\n        });\n    }\n\n    #[test]\n    fn test_get_dist_url_with_mirror() {\n        vite_shared::EnvConfig::test_scope(\n            vite_shared::EnvConfig {\n                node_dist_mirror: Some(\"https://nodejs.org/dist\".into()),\n                ..vite_shared::EnvConfig::for_test()\n            },\n            || {\n                assert_eq!(get_dist_url(), \"https://nodejs.org/dist\");\n            },\n        );\n    }\n\n    #[test]\n    fn test_get_dist_url_trims_trailing_slash() {\n        vite_shared::EnvConfig::test_scope(\n            vite_shared::EnvConfig {\n                node_dist_mirror: Some(\"https://nodejs.org/dist/\".into()),\n                ..vite_shared::EnvConfig::for_test()\n            },\n            || {\n                assert_eq!(get_dist_url(), \"https://nodejs.org/dist\");\n            },\n        );\n    }\n\n    #[test]\n    fn test_parse_lts_info() {\n        // Test parsing different LTS formats\n        let json_not_lts = r#\"{\"version\": \"v23.0.0\", \"lts\": false}\"#;\n        let entry: NodeVersionEntry = serde_json::from_str(json_not_lts).unwrap();\n        assert!(matches!(entry.lts, LtsInfo::Boolean(false)));\n\n        let json_lts_codename = r#\"{\"version\": \"v22.12.0\", \"lts\": \"Jod\"}\"#;\n        let entry: NodeVersionEntry = serde_json::from_str(json_lts_codename).unwrap();\n        assert!(matches!(entry.lts, LtsInfo::Codename(_)));\n\n        let json_no_lts = r#\"{\"version\": \"v23.0.0\"}\"#;\n        let entry: NodeVersionEntry = serde_json::from_str(json_no_lts).unwrap();\n        assert!(matches!(entry.lts, LtsInfo::NotLts));\n    }\n\n    #[tokio::test]\n    async fn test_fetch_version_index() {\n        let provider = NodeProvider::new();\n        let versions = provider.fetch_version_index().await.unwrap();\n\n        // Should have at least some versions\n        assert!(!versions.is_empty());\n\n        // First entry should be the latest version\n        let first = &versions[0];\n        assert!(first.version.starts_with('v'));\n\n        // Should contain some known versions\n        let has_v20 = versions.iter().any(|v| v.version.starts_with(\"v20.\"));\n        assert!(has_v20, \"Should contain Node.js v20.x versions\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_caret() {\n        use super::resolve_version_from_list;\n\n        // Mock version data in random order\n        let versions = vec![\n            NodeVersionEntry { version: \"v20.17.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.19.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v21.0.0\".into(), lts: LtsInfo::Boolean(false) },\n            NodeVersionEntry { version: \"v20.20.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // ^20.18.0 should match highest 20.x.x >= 20.18.0\n        let result = resolve_version_from_list(\"^20.18.0\", &versions).unwrap();\n        assert_eq!(result, \"20.20.0\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_tilde() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.18.3\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.19.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.18.1\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.18.5\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // ~20.18.0 should match highest 20.18.x\n        let result = resolve_version_from_list(\"~20.18.0\", &versions).unwrap();\n        assert_eq!(result, \"20.18.5\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_exact() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v20.17.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.19.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // Exact version should return that specific version\n        let result = resolve_version_from_list(\"20.18.0\", &versions).unwrap();\n        assert_eq!(result, \"20.18.0\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_range() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry {\n                version: \"v18.20.0\".into(),\n                lts: LtsInfo::Codename(\"Hydrogen\".into()),\n            },\n            NodeVersionEntry { version: \"v20.15.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v22.5.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v22.10.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n        ];\n\n        // >=20.0.0 <22.0.0 should match highest in range (20.18.0)\n        let result = resolve_version_from_list(\">=20.0.0 <22.0.0\", &versions).unwrap();\n        assert_eq!(result, \"20.18.0\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_no_match() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v22.5.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n        ];\n\n        // Version that doesn't exist\n        let result = resolve_version_from_list(\"^999.0.0\", &versions);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_empty() {\n        use super::resolve_version_from_list;\n\n        let versions: Vec<NodeVersionEntry> = vec![];\n        let result = resolve_version_from_list(\"^20.0.0\", &versions);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_invalid_range() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![NodeVersionEntry {\n            version: \"v20.18.0\".into(),\n            lts: LtsInfo::Codename(\"Iron\".into()),\n        }];\n\n        // Invalid semver range\n        let result = resolve_version_from_list(\"invalid-range\", &versions);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_unordered_finds_max() {\n        use super::resolve_version_from_list;\n\n        // Versions in completely random order - the key test case\n        let versions = vec![\n            NodeVersionEntry { version: \"v20.15.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.20.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.10.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.12.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // Should find the maximum (20.20.0), not the first (20.15.0)\n        let result = resolve_version_from_list(\"^20.0.0\", &versions).unwrap();\n        assert_eq!(result, \"20.20.0\");\n    }\n\n    #[test]\n    fn test_find_latest_lts_version() {\n        use super::find_latest_lts_version;\n\n        // Mock version data simulating Node.js index.json structure\n        // Note: The index is typically sorted by version descending, but our logic\n        // should find the highest LTS version regardless of order\n        let versions = vec![\n            // Latest non-LTS (Current)\n            NodeVersionEntry { version: \"v23.5.0\".into(), lts: LtsInfo::Boolean(false) },\n            NodeVersionEntry { version: \"v23.4.0\".into(), lts: LtsInfo::Boolean(false) },\n            // Latest LTS line (Jod) - v22.x\n            NodeVersionEntry { version: \"v22.13.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v22.12.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            // Older LTS line (Iron) - v20.x\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v20.17.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            // Even older LTS\n            NodeVersionEntry {\n                version: \"v18.20.0\".into(),\n                lts: LtsInfo::Codename(\"Hydrogen\".into()),\n            },\n        ];\n\n        let result = find_latest_lts_version(&versions).unwrap();\n\n        // Should return v22.13.0 - the highest version that is LTS\n        assert_eq!(result, \"22.13.0\");\n    }\n\n    #[test]\n    fn test_find_latest_lts_version_unordered() {\n        use super::find_latest_lts_version;\n\n        // Test with versions in random order to ensure we find max, not first\n        let versions = vec![\n            NodeVersionEntry { version: \"v20.18.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n            NodeVersionEntry { version: \"v23.5.0\".into(), lts: LtsInfo::Boolean(false) },\n            NodeVersionEntry { version: \"v22.12.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry {\n                version: \"v18.20.0\".into(),\n                lts: LtsInfo::Codename(\"Hydrogen\".into()),\n            },\n            NodeVersionEntry { version: \"v22.13.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n        ];\n\n        let result = find_latest_lts_version(&versions).unwrap();\n\n        // Should still return v22.13.0 - the highest LTS version\n        assert_eq!(result, \"22.13.0\");\n    }\n\n    #[test]\n    fn test_find_latest_lts_version_no_lts() {\n        use super::find_latest_lts_version;\n\n        // Test with no LTS versions\n        let versions = vec![\n            NodeVersionEntry { version: \"v23.5.0\".into(), lts: LtsInfo::Boolean(false) },\n            NodeVersionEntry { version: \"v23.4.0\".into(), lts: LtsInfo::Boolean(false) },\n            NodeVersionEntry { version: \"v23.3.0\".into(), lts: LtsInfo::NotLts },\n        ];\n\n        let result = find_latest_lts_version(&versions);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_find_latest_lts_version_empty() {\n        use super::find_latest_lts_version;\n\n        let versions: Vec<NodeVersionEntry> = vec![];\n        let result = find_latest_lts_version(&versions);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_is_lts() {\n        let lts_entry: NodeVersionEntry =\n            serde_json::from_str(r#\"{\"version\": \"v22.12.0\", \"lts\": \"Jod\"}\"#).unwrap();\n        assert!(lts_entry.is_lts());\n\n        let non_lts_entry: NodeVersionEntry =\n            serde_json::from_str(r#\"{\"version\": \"v23.0.0\", \"lts\": false}\"#).unwrap();\n        assert!(!non_lts_entry.is_lts());\n\n        let no_lts_field: NodeVersionEntry =\n            serde_json::from_str(r#\"{\"version\": \"v23.0.0\"}\"#).unwrap();\n        assert!(!no_lts_field.is_lts());\n    }\n\n    #[test]\n    fn test_is_exact_version() {\n        // Exact versions should return true\n        assert!(NodeProvider::is_exact_version(\"20.18.0\"));\n        assert!(NodeProvider::is_exact_version(\"22.13.1\"));\n        assert!(NodeProvider::is_exact_version(\"18.20.5\"));\n        assert!(NodeProvider::is_exact_version(\"0.0.1\"));\n        assert!(NodeProvider::is_exact_version(\"v20.18.0\")); // With 'v' prefix is also exact\n\n        // Ranges and partial versions should return false\n        assert!(!NodeProvider::is_exact_version(\"^20.18.0\"));\n        assert!(!NodeProvider::is_exact_version(\"~20.18.0\"));\n        assert!(!NodeProvider::is_exact_version(\">=20.0.0\"));\n        assert!(!NodeProvider::is_exact_version(\">=20 <22\"));\n        assert!(!NodeProvider::is_exact_version(\"20.x\"));\n        assert!(!NodeProvider::is_exact_version(\"20.*\"));\n        assert!(!NodeProvider::is_exact_version(\">20.18.0\"));\n        assert!(!NodeProvider::is_exact_version(\"<22.0.0\"));\n        assert!(!NodeProvider::is_exact_version(\"20\")); // Major only\n        assert!(!NodeProvider::is_exact_version(\"20.18\")); // Major.minor only\n\n        // Invalid versions should return false\n        assert!(!NodeProvider::is_exact_version(\"invalid\"));\n        assert!(!NodeProvider::is_exact_version(\"\"));\n    }\n\n    #[tokio::test]\n    async fn test_find_cached_version() {\n        use tempfile::TempDir;\n        use vite_path::AbsolutePathBuf;\n\n        let temp_dir = TempDir::new().unwrap();\n        let cache_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n        let provider = NodeProvider::new();\n\n        // Initially, no cache exists\n        let result = provider.find_cached_version(\"^20.18.0\", &cache_dir).await.unwrap();\n        assert!(result.is_none());\n\n        // Create mock cached versions\n        let node_cache = cache_dir.join(\"node\");\n        tokio::fs::create_dir_all(&node_cache).await.unwrap();\n\n        // Create version directories with mock binary\n        let platform = Platform::current();\n        let binary_path = provider.binary_relative_path(platform);\n\n        for version in [\"20.17.0\", \"20.18.0\", \"20.19.0\", \"21.0.0\"] {\n            let version_dir = node_cache.join(version);\n            let binary_full_path = version_dir.join(&binary_path);\n            tokio::fs::create_dir_all(binary_full_path.parent().unwrap()).await.unwrap();\n            tokio::fs::write(&binary_full_path, \"mock binary\").await.unwrap();\n        }\n\n        // Create incomplete installation (no binary)\n        let incomplete_dir = node_cache.join(\"20.20.0\");\n        tokio::fs::create_dir_all(&incomplete_dir).await.unwrap();\n\n        // Test: ^20.18.0 should find highest matching version (20.19.0)\n        let result = provider.find_cached_version(\"^20.18.0\", &cache_dir).await.unwrap();\n        assert_eq!(result, Some(\"20.19.0\".into()));\n\n        // Test: ~20.18.0 should find highest 20.18.x (only 20.18.0)\n        let result = provider.find_cached_version(\"~20.18.0\", &cache_dir).await.unwrap();\n        assert_eq!(result, Some(\"20.18.0\".into()));\n\n        // Test: ^21.0.0 should find 21.0.0\n        let result = provider.find_cached_version(\"^21.0.0\", &cache_dir).await.unwrap();\n        assert_eq!(result, Some(\"21.0.0\".into()));\n\n        // Test: ^22.0.0 should find nothing\n        let result = provider.find_cached_version(\"^22.0.0\", &cache_dir).await.unwrap();\n        assert!(result.is_none());\n\n        // Test: ^20.20.0 should find nothing (20.20.0 exists but no binary)\n        let result = provider.find_cached_version(\"^20.20.0\", &cache_dir).await.unwrap();\n        assert!(result.is_none());\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_prefers_lts() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v25.5.0\".into(), lts: LtsInfo::NotLts },\n            NodeVersionEntry { version: \"v24.5.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v22.15.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v20.19.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // Should prefer highest LTS (v24.5.0) over non-LTS (v25.5.0)\n        let result = resolve_version_from_list(\">=20.0.0\", &versions).unwrap();\n        assert_eq!(result, \"24.5.0\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_falls_back_to_non_lts() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v25.5.0\".into(), lts: LtsInfo::NotLts },\n            NodeVersionEntry { version: \"v25.4.0\".into(), lts: LtsInfo::NotLts },\n        ];\n\n        // No LTS matches, should return highest non-LTS\n        let result = resolve_version_from_list(\">24.9999.0\", &versions).unwrap();\n        assert_eq!(result, \"25.5.0\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_complex_range_prefers_lts() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v25.5.0\".into(), lts: LtsInfo::NotLts },\n            NodeVersionEntry { version: \"v24.5.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v22.15.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v20.19.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // ^20.19.0 || >=22.12.0 should prefer v24.5.0 (highest LTS) over v25.5.0\n        let result = resolve_version_from_list(\"^20.19.0 || >=22.12.0\", &versions).unwrap();\n        assert_eq!(result, \"24.5.0\");\n    }\n\n    #[test]\n    fn test_resolve_version_from_list_only_matches_in_range_lts() {\n        use super::resolve_version_from_list;\n\n        let versions = vec![\n            NodeVersionEntry { version: \"v25.5.0\".into(), lts: LtsInfo::NotLts },\n            NodeVersionEntry { version: \"v24.5.0\".into(), lts: LtsInfo::Codename(\"Jod\".into()) },\n            NodeVersionEntry { version: \"v20.19.0\".into(), lts: LtsInfo::Codename(\"Iron\".into()) },\n        ];\n\n        // ^20.18.0 should return 20.19.0 (the only LTS in range)\n        let result = resolve_version_from_list(\"^20.18.0\", &versions).unwrap();\n        assert_eq!(result, \"20.19.0\");\n    }\n\n    // ========================================================================\n    // LTS Alias Tests\n    // ========================================================================\n\n    #[test]\n    fn test_is_lts_alias() {\n        // Valid LTS aliases\n        assert!(NodeProvider::is_lts_alias(\"lts/*\"));\n        assert!(NodeProvider::is_lts_alias(\"lts/iron\"));\n        assert!(NodeProvider::is_lts_alias(\"lts/jod\"));\n        assert!(NodeProvider::is_lts_alias(\"lts/Iron\")); // Case-insensitive for codename\n        assert!(NodeProvider::is_lts_alias(\"lts/Jod\"));\n        assert!(NodeProvider::is_lts_alias(\"lts/hydrogen\"));\n        assert!(NodeProvider::is_lts_alias(\"lts/-1\")); // Offset format\n        assert!(NodeProvider::is_lts_alias(\"lts/-2\"));\n\n        // Not LTS aliases\n        assert!(!NodeProvider::is_lts_alias(\"20.18.0\")); // Exact version\n        assert!(!NodeProvider::is_lts_alias(\"^20.0.0\")); // Semver range\n        assert!(!NodeProvider::is_lts_alias(\"20\")); // Partial version\n        assert!(!NodeProvider::is_lts_alias(\"iron\")); // Codename without lts/ prefix\n        assert!(!NodeProvider::is_lts_alias(\"Lts/*\")); // Wrong case for prefix\n        assert!(!NodeProvider::is_lts_alias(\"LTS/*\")); // All caps prefix\n        assert!(!NodeProvider::is_lts_alias(\"\")); // Empty\n        assert!(!NodeProvider::is_lts_alias(\"latest\")); // Different alias\n        assert!(!NodeProvider::is_lts_alias(\"lts\")); // No suffix\n    }\n\n    #[test]\n    fn test_is_latest_alias() {\n        // Valid \"latest\" aliases (case-insensitive)\n        assert!(NodeProvider::is_latest_alias(\"latest\"));\n        assert!(NodeProvider::is_latest_alias(\"Latest\"));\n        assert!(NodeProvider::is_latest_alias(\"LATEST\"));\n\n        // Not \"latest\" aliases\n        assert!(!NodeProvider::is_latest_alias(\"lts/*\"));\n        assert!(!NodeProvider::is_latest_alias(\"20.18.0\"));\n        assert!(!NodeProvider::is_latest_alias(\"^20.0.0\"));\n        assert!(!NodeProvider::is_latest_alias(\"\"));\n        assert!(!NodeProvider::is_latest_alias(\"late\"));\n        assert!(!NodeProvider::is_latest_alias(\"latestversion\"));\n    }\n\n    #[test]\n    fn test_is_version_alias() {\n        // LTS aliases\n        assert!(NodeProvider::is_version_alias(\"lts/*\"));\n        assert!(NodeProvider::is_version_alias(\"lts/iron\"));\n\n        // \"latest\" alias\n        assert!(NodeProvider::is_version_alias(\"latest\"));\n        assert!(NodeProvider::is_version_alias(\"LATEST\"));\n\n        // Not aliases\n        assert!(!NodeProvider::is_version_alias(\"20.18.0\"));\n        assert!(!NodeProvider::is_version_alias(\"^20.0.0\"));\n        assert!(!NodeProvider::is_version_alias(\"\"));\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_latest() {\n        let provider = NodeProvider::new();\n\n        // lts/* should resolve to the latest LTS version\n        let version = provider.resolve_lts_alias(\"lts/*\").await.unwrap();\n\n        // Should be a valid semver version\n        let parsed = Version::parse(&version).expect(\"Should parse as semver\");\n\n        // As of 2026, latest LTS is at least v24.x (Krypton)\n        assert!(parsed.major >= 24, \"Latest LTS should be at least v24.x, got {}\", version);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_codename_iron() {\n        let provider = NodeProvider::new();\n\n        // lts/iron should resolve to v20.x\n        let version = provider.resolve_lts_alias(\"lts/iron\").await.unwrap();\n        let parsed = Version::parse(&version).expect(\"Should parse as semver\");\n        assert_eq!(parsed.major, 20, \"lts/iron should resolve to v20.x, got {}\", version);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_codename_jod() {\n        let provider = NodeProvider::new();\n\n        // lts/jod should resolve to v22.x\n        let version = provider.resolve_lts_alias(\"lts/jod\").await.unwrap();\n        let parsed = Version::parse(&version).expect(\"Should parse as semver\");\n        assert_eq!(parsed.major, 22, \"lts/jod should resolve to v22.x, got {}\", version);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_codename_case_insensitive() {\n        let provider = NodeProvider::new();\n\n        // Should be case-insensitive for codenames\n        let version_lower = provider.resolve_lts_alias(\"lts/iron\").await.unwrap();\n        let version_mixed = provider.resolve_lts_alias(\"lts/Iron\").await.unwrap();\n\n        assert_eq!(version_lower, version_mixed, \"LTS codename should be case-insensitive\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_offset() {\n        let provider = NodeProvider::new();\n\n        // lts/-1 should resolve to the second-highest LTS line\n        // As of 2026: lts/* = 24.x (Krypton), lts/-1 = 22.x (Jod)\n        let version = provider.resolve_lts_alias(\"lts/-1\").await.unwrap();\n        let parsed = Version::parse(&version).expect(\"Should parse as semver\");\n        assert_eq!(parsed.major, 22, \"lts/-1 should resolve to v22.x (Jod), got {}\", version);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_unknown_codename() {\n        let provider = NodeProvider::new();\n\n        // Unknown codename should error\n        let result = provider.resolve_lts_alias(\"lts/unknown\").await;\n        assert!(result.is_err(), \"Unknown LTS codename should return error\");\n    }\n\n    #[tokio::test]\n    async fn test_resolve_lts_alias_invalid_offset() {\n        let provider = NodeProvider::new();\n\n        // Too large offset should error (there aren't 100 LTS lines)\n        let result = provider.resolve_lts_alias(\"lts/-100\").await;\n        assert!(result.is_err(), \"Invalid LTS offset should return error\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_js_runtime/src/runtime.rs",
    "content": "use node_semver::{Range, Version};\nuse tempfile::TempDir;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_str::Str;\n\nuse crate::{\n    Error, Platform,\n    dev_engines::{PackageJson, read_node_version_file},\n    download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash},\n    provider::{HashVerification, JsRuntimeProvider},\n    providers::NodeProvider,\n};\n\n/// Supported JavaScript runtime types\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum JsRuntimeType {\n    Node,\n    // Future: Bun, Deno\n}\n\nimpl std::fmt::Display for JsRuntimeType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::Node => write!(f, \"node\"),\n        }\n    }\n}\n\n/// Represents a downloaded JavaScript runtime\n#[derive(Debug)]\npub struct JsRuntime {\n    pub runtime_type: JsRuntimeType,\n    pub version: Str,\n    pub install_dir: AbsolutePathBuf,\n    /// Relative path from `install_dir` to the binary\n    binary_relative_path: Str,\n    /// Relative path from `install_dir` to the bin directory\n    bin_dir_relative_path: Str,\n}\n\nimpl JsRuntime {\n    /// Get the path to the runtime binary (e.g., node, bun)\n    #[must_use]\n    pub fn get_binary_path(&self) -> AbsolutePathBuf {\n        self.install_dir.join(&self.binary_relative_path)\n    }\n\n    /// Get the bin directory containing the runtime\n    #[must_use]\n    pub fn get_bin_prefix(&self) -> AbsolutePathBuf {\n        if self.bin_dir_relative_path.is_empty() {\n            self.install_dir.clone()\n        } else {\n            self.install_dir.join(&self.bin_dir_relative_path)\n        }\n    }\n\n    /// Get the runtime type\n    #[must_use]\n    pub const fn runtime_type(&self) -> JsRuntimeType {\n        self.runtime_type\n    }\n\n    /// Get the version string\n    #[must_use]\n    pub fn version(&self) -> &str {\n        &self.version\n    }\n}\n\n/// Download and cache a JavaScript runtime\n///\n/// # Arguments\n/// * `runtime_type` - The type of runtime to download\n/// * `version` - The exact version (e.g., \"22.13.1\")\n///\n/// # Returns\n/// A `JsRuntime` instance with the installation path\n///\n/// # Errors\n/// Returns an error if download, verification, or extraction fails\npub async fn download_runtime(\n    runtime_type: JsRuntimeType,\n    version: &str,\n) -> Result<JsRuntime, Error> {\n    match runtime_type {\n        JsRuntimeType::Node => {\n            let provider = NodeProvider::new();\n            download_runtime_with_provider(&provider, JsRuntimeType::Node, version).await\n        }\n    }\n}\n\n/// Download and cache a JavaScript runtime using a provider\n///\n/// This is the generic download function that works with any `JsRuntimeProvider`.\n///\n/// # Errors\n///\n/// Returns an error if download, verification, or extraction fails.\n///\n/// # Panics\n///\n/// Panics if the temp directory path is not absolute (should not happen in practice).\npub async fn download_runtime_with_provider<P: JsRuntimeProvider>(\n    provider: &P,\n    runtime_type: JsRuntimeType,\n    version: &str,\n) -> Result<JsRuntime, Error> {\n    let platform = Platform::current();\n    let cache_dir = crate::cache::get_cache_dir()?;\n\n    // Get paths from provider\n    let binary_relative_path = provider.binary_relative_path(platform);\n    let bin_dir_relative_path = provider.bin_dir_relative_path(platform);\n\n    // Cache path: $CACHE_DIR/vite-plus/js_runtime/{runtime}/{version}/\n    let install_dir = cache_dir.join(provider.name()).join(version);\n\n    // Check if already cached\n    let binary_path = install_dir.join(&binary_relative_path);\n    if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) {\n        tracing::debug!(\"{} {version} already cached at {install_dir:?}\", provider.name());\n        return Ok(JsRuntime {\n            runtime_type,\n            version: version.into(),\n            install_dir,\n            binary_relative_path,\n            bin_dir_relative_path,\n        });\n    }\n\n    // If install_dir exists but binary doesn't, it's an incomplete installation - clean it up\n    if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) {\n        tracing::warn!(\n            \"Incomplete installation detected at {install_dir:?}, removing before re-download\"\n        );\n        tokio::fs::remove_dir_all(&install_dir).await?;\n    }\n\n    let download_message = format!(\"Downloading {} v{version}...\", provider.name());\n    tracing::info!(\"{download_message}\");\n\n    // Get download info from provider\n    let download_info = provider.get_download_info(version, platform);\n\n    // Create temp directory for download under cache_dir to ensure rename works\n    // (rename fails with EXDEV if source and target are on different filesystems)\n    tokio::fs::create_dir_all(&cache_dir).await?;\n    let temp_dir = TempDir::new_in(&cache_dir)?;\n    let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n    let archive_path = temp_path.join(&download_info.archive_filename);\n\n    // Verify hash if verification method is provided\n    match &download_info.hash_verification {\n        HashVerification::ShasumsFile { url } => {\n            let shasums_content = download_text(url).await?;\n            let expected_hash =\n                provider.parse_shasums(&shasums_content, &download_info.archive_filename)?;\n\n            // Download archive\n            download_file(&download_info.archive_url, &archive_path, &download_message).await?;\n\n            // Verify hash\n            verify_file_hash(&archive_path, &expected_hash, &download_info.archive_filename)\n                .await?;\n        }\n        HashVerification::None => {\n            // Download archive without verification\n            download_file(&download_info.archive_url, &archive_path, &download_message).await?;\n        }\n    }\n\n    // Extract archive\n    extract_archive(&archive_path, &temp_path, download_info.archive_format).await?;\n\n    // Move extracted directory to cache location\n    let extracted_path = temp_path.join(&download_info.extracted_dir_name);\n    move_to_cache(&extracted_path, &install_dir, version).await?;\n\n    tracing::info!(\"{} {version} installed at {install_dir:?}\", provider.name());\n\n    Ok(JsRuntime {\n        runtime_type,\n        version: version.into(),\n        install_dir,\n        binary_relative_path,\n        bin_dir_relative_path,\n    })\n}\n\n/// Represents the source from which a Node.js version was read.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum VersionSource {\n    /// Version from `.node-version` file (highest priority)\n    NodeVersionFile,\n    /// Version from `engines.node` in package.json\n    EnginesNode,\n    /// Version from `devEngines.runtime` in package.json (lowest priority)\n    DevEnginesRuntime,\n}\n\nimpl std::fmt::Display for VersionSource {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::NodeVersionFile => write!(f, \".node-version\"),\n            Self::EnginesNode => write!(f, \"engines.node\"),\n            Self::DevEnginesRuntime => write!(f, \"devEngines.runtime\"),\n        }\n    }\n}\n\n/// Resolved version information with source tracking.\n#[derive(Debug, Clone)]\npub struct VersionResolution {\n    /// The resolved version string (e.g., \"20.18.0\" or \"^20.18.0\")\n    pub version: Str,\n    /// The source type of the version\n    pub source: VersionSource,\n    /// Path to the source file (e.g., .node-version or package.json)\n    pub source_path: Option<AbsolutePathBuf>,\n    /// Project root directory (the directory containing the version source)\n    pub project_root: Option<AbsolutePathBuf>,\n}\n\n/// Resolve Node.js version from project configuration.\n///\n/// At each directory level, searches for version in the following priority order:\n/// 1. `.node-version` file\n/// 2. `package.json#engines.node`\n/// 3. `package.json#devEngines.runtime[name=\"node\"]`\n///\n/// If `walk_up` is true, walks up the directory tree checking each level until\n/// a version is found or the root is reached.\n///\n/// # Arguments\n/// * `start_dir` - The directory to start searching from\n/// * `walk_up` - Whether to walk up the directory tree\n///\n/// # Returns\n/// `Some(VersionResolution)` if a version source is found, `None` otherwise.\n///\n/// # Errors\n/// Returns an error if file reading fails.\npub async fn resolve_node_version(\n    start_dir: &AbsolutePath,\n    walk_up: bool,\n) -> Result<Option<VersionResolution>, Error> {\n    let mut current = start_dir.to_owned();\n\n    loop {\n        // At each directory level, check both .node-version and package.json\n        // before moving to parent directory\n\n        // 1. Check .node-version file\n        if let Some(version) = read_node_version_file(&current).await {\n            let node_version_path = current.join(\".node-version\");\n            return Ok(Some(VersionResolution {\n                version,\n                source: VersionSource::NodeVersionFile,\n                source_path: Some(node_version_path),\n                project_root: Some(current.to_absolute_path_buf()),\n            }));\n        }\n\n        // 2-3. Check package.json (engines.node and devEngines.runtime)\n        let package_json_path = current.join(\"package.json\");\n        if tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) {\n            let content = tokio::fs::read_to_string(&package_json_path).await?;\n            if let Ok(pkg) = serde_json::from_str::<PackageJson>(&content) {\n                // Check engines.node first\n                if let Some(engines) = &pkg.engines {\n                    if let Some(node) = &engines.node {\n                        if !node.is_empty() {\n                            return Ok(Some(VersionResolution {\n                                version: node.clone(),\n                                source: VersionSource::EnginesNode,\n                                source_path: Some(package_json_path),\n                                project_root: Some(current.to_absolute_path_buf()),\n                            }));\n                        }\n                    }\n                }\n\n                // Check devEngines.runtime\n                if let Some(dev_engines) = &pkg.dev_engines {\n                    if let Some(runtime) = &dev_engines.runtime {\n                        if let Some(node_rt) = runtime.find_by_name(\"node\") {\n                            if !node_rt.version.is_empty() {\n                                return Ok(Some(VersionResolution {\n                                    version: node_rt.version.clone(),\n                                    source: VersionSource::DevEnginesRuntime,\n                                    source_path: Some(package_json_path),\n                                    project_root: Some(current.to_absolute_path_buf()),\n                                }));\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        // Move to parent directory if walk_up is enabled\n        if !walk_up {\n            break;\n        }\n\n        match current.parent() {\n            Some(parent) => current = parent.to_owned(),\n            None => break,\n        }\n    }\n\n    // No version source found\n    Ok(None)\n}\n\n/// Download runtime based on project's version configuration.\n///\n/// Reads Node.js version from multiple sources with the following priority:\n/// 1. `.node-version` file (highest)\n/// 2. `engines.node` in package.json\n/// 3. `devEngines.runtime` in package.json (lowest)\n///\n/// If no version source is found, uses the latest installed version from cache,\n/// or falls back to the latest LTS version from the network.\n///\n/// When the resolved version from the highest priority source does NOT satisfy\n/// constraints from lower priority sources, a warning is emitted.\n///\n/// # Arguments\n/// * `project_path` - The path to the project directory\n///\n/// # Returns\n/// A `JsRuntime` instance with the installation path\n///\n/// # Errors\n/// Returns an error if version resolution fails or download/extraction fails.\n///\n/// # Note\n/// Currently only supports Node.js runtime.\npub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result<JsRuntime, Error> {\n    let provider = NodeProvider::new();\n    let cache_dir = crate::cache::get_cache_dir()?;\n\n    // Resolve version from the project directory, walking up to inherit from ancestors\n    let resolution = resolve_node_version(project_path, true).await?;\n\n    // Validate the version from the resolved source\n    let version_req =\n        resolution.as_ref().and_then(|r| normalize_version(&r.version, &r.source.to_string()));\n\n    // For compatibility checking, we need to read all sources from the local package.json\n    let package_json_path = project_path.join(\"package.json\");\n    let pkg = read_package_json(&package_json_path).await?;\n\n    let engines_node = pkg\n        .as_ref()\n        .and_then(|p| p.engines.as_ref())\n        .and_then(|e| e.node.clone())\n        .and_then(|v| normalize_version(&v, \"engines.node\"));\n\n    let dev_engines_runtime = pkg\n        .as_ref()\n        .and_then(|p| p.dev_engines.as_ref())\n        .and_then(|de| de.runtime.as_ref())\n        .and_then(|rt| rt.find_by_name(\"node\"))\n        .map(|r| r.version.clone())\n        .filter(|v| !v.is_empty())\n        .and_then(|v| normalize_version(&v, \"devEngines.runtime\"));\n\n    // Determine the actual version requirement to use\n    let (version_req, source) = if let Some(ref v) = version_req {\n        (v.clone(), resolution.as_ref().map(|r| r.source))\n    } else if let Some(ref v) = engines_node {\n        // Fall through if primary source was invalid\n        (v.clone(), Some(VersionSource::EnginesNode))\n    } else if let Some(ref v) = dev_engines_runtime {\n        (v.clone(), Some(VersionSource::DevEnginesRuntime))\n    } else {\n        (Str::default(), None)\n    };\n\n    tracing::debug!(\"Selected version source: {source:?}, version_req: {version_req:?}\");\n\n    // Resolve version (if range/partial → exact)\n    let version = resolve_version_for_project(&version_req, &provider, &cache_dir).await?;\n\n    // Check compatibility with lower priority sources\n    check_version_compatibility(&version, source, &engines_node, &dev_engines_runtime);\n\n    tracing::info!(\"Resolved Node.js version: {version}\");\n    let runtime = download_runtime(JsRuntimeType::Node, &version).await?;\n\n    Ok(runtime)\n}\n\n/// Resolve version requirement to an exact version.\n///\n/// Returns the resolved exact version string.\nasync fn resolve_version_for_project(\n    version_req: &str,\n    provider: &NodeProvider,\n    cache_dir: &AbsolutePath,\n) -> Result<Str, Error> {\n    if version_req.is_empty() {\n        // No source specified - fetch latest LTS from network\n        tracing::debug!(\"No version source specified, fetching latest LTS from network\");\n        return provider.resolve_latest_version().await;\n    }\n\n    // Handle LTS aliases (lts/*, lts/iron, lts/-1)\n    if NodeProvider::is_lts_alias(version_req) {\n        tracing::debug!(\"Resolving LTS alias: {version_req}\");\n        return provider.resolve_lts_alias(version_req).await;\n    }\n\n    // Handle \"latest\" alias - resolves to absolute latest version (including non-LTS)\n    if NodeProvider::is_latest_alias(version_req) {\n        tracing::debug!(\"Resolving 'latest' alias\");\n        return provider.resolve_version(\"*\").await;\n    }\n\n    // Check if it's an exact version\n    if NodeProvider::is_exact_version(version_req) {\n        let normalized = version_req.strip_prefix('v').unwrap_or(version_req);\n        tracing::debug!(\"Using exact version: {normalized}\");\n        return Ok(normalized.into());\n    }\n\n    // Check local cache first\n    if let Some(cached) = provider.find_cached_version(version_req, cache_dir).await? {\n        tracing::debug!(\"Found cached version {cached} satisfying {version_req}\");\n        return Ok(cached);\n    }\n\n    // Resolve from network\n    tracing::debug!(\"Resolving version requirement from network: {version_req}\");\n    provider.resolve_version(version_req).await\n}\n\n/// Check if the resolved version is compatible with lower priority sources.\n/// Emit warnings if incompatible.\nfn check_version_compatibility(\n    resolved_version: &str,\n    source: Option<VersionSource>,\n    engines_node: &Option<Str>,\n    dev_engines_runtime: &Option<Str>,\n) {\n    let parsed = match Version::parse(resolved_version) {\n        Ok(v) => v,\n        Err(_) => return, // Can't check compatibility without a valid version\n    };\n\n    // Check engines.node if it's a lower priority source\n    if source != Some(VersionSource::EnginesNode) {\n        if let Some(req) = engines_node {\n            check_constraint(&parsed, req, \"engines.node\", resolved_version, source);\n        }\n    }\n\n    // Check devEngines.runtime if it's a lower priority source\n    if source != Some(VersionSource::DevEnginesRuntime) {\n        if let Some(req) = dev_engines_runtime {\n            check_constraint(&parsed, req, \"devEngines.runtime\", resolved_version, source);\n        }\n    }\n}\n\n/// Check if a version satisfies a constraint and warn if not.\nfn check_constraint(\n    version: &Version,\n    constraint: &str,\n    constraint_source: &str,\n    resolved_version: &str,\n    source: Option<VersionSource>,\n) {\n    match Range::parse(constraint) {\n        Ok(range) => {\n            if !range.satisfies(version) {\n                let source_str = source.map_or(\"none\".to_string(), |s| s.to_string());\n                println!(\n                    \"warning: Node.js version {resolved_version} (from {source_str}) does not \\\n                     satisfy {constraint_source} constraint '{constraint}'\"\n                );\n            }\n        }\n        Err(e) => {\n            tracing::debug!(\"Failed to parse {constraint_source} constraint '{constraint}': {e}\");\n        }\n    }\n}\n\n/// Check if a version string is valid (exact version, range, or LTS alias).\n/// Trims whitespace before checking. Does not print warnings.\n#[must_use]\npub fn is_valid_version(version: &str) -> bool {\n    let trimmed = version.trim();\n\n    if trimmed.is_empty() {\n        return false;\n    }\n\n    // Accept version aliases (lts/*, lts/iron, lts/-1, latest)\n    if NodeProvider::is_version_alias(trimmed) {\n        return true;\n    }\n\n    // Try parsing as exact version (strip 'v' prefix for exact version check)\n    let without_v = trimmed.strip_prefix('v').unwrap_or(trimmed);\n    if Version::parse(without_v).is_ok() {\n        return true;\n    }\n\n    // Try parsing as range\n    Range::parse(trimmed).is_ok()\n}\n\n/// Normalize and validate a version string as semver (exact version or range) or LTS alias.\n/// Trims whitespace and returns the normalized version, or None with a warning if invalid.\npub fn normalize_version(version: &Str, source: &str) -> Option<Str> {\n    let trimmed: Str = version.trim().into();\n\n    if is_valid_version(&trimmed) {\n        return Some(trimmed);\n    }\n\n    // Invalid version — print warning (only if non-empty, empty is just \"not specified\")\n    if !trimmed.is_empty() {\n        println!(\"warning: invalid version '{version}' in {source}, ignoring\");\n    }\n    None\n}\n\n/// Read package.json contents.\npub async fn read_package_json(\n    package_json_path: &AbsolutePathBuf,\n) -> Result<Option<PackageJson>, Error> {\n    if !tokio::fs::try_exists(package_json_path).await.unwrap_or(false) {\n        tracing::debug!(\"package.json not found at {:?}\", package_json_path);\n        return Ok(None);\n    }\n\n    let content = tokio::fs::read_to_string(package_json_path).await?;\n    let pkg: PackageJson = serde_json::from_str(&content)?;\n    Ok(Some(pkg))\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n\n    use super::*;\n\n    #[test]\n    fn test_js_runtime_type_display() {\n        assert_eq!(JsRuntimeType::Node.to_string(), \"node\");\n    }\n\n    /// Test that install_dir path is constructed correctly without embedded forward slashes.\n    /// This ensures Windows compatibility by using separate join() calls.\n    #[test]\n    fn test_install_dir_path_construction() {\n        let cache_dir = AbsolutePathBuf::new(std::path::PathBuf::from(if cfg!(windows) {\n            \"C:\\\\Users\\\\test\\\\.cache\\\\vite-plus\\\\js_runtime\"\n        } else {\n            \"/home/test/.cache/vite-plus/js_runtime\"\n        }))\n        .unwrap();\n\n        let provider_name = \"node\";\n        let version = \"20.18.0\";\n\n        // This is how install_dir is constructed in download_runtime_with_provider\n        let install_dir = cache_dir.join(provider_name).join(version);\n\n        // The path should use native separators, not embedded forward slashes\n        let path_str = install_dir.as_path().to_string_lossy();\n        if cfg!(windows) {\n            // On Windows, we should have backslashes, not forward slashes\n            assert!(\n                !path_str.contains(\"node/\"),\n                \"Path should not contain 'node/' on Windows: {path_str}\"\n            );\n            assert!(\n                path_str.contains(\"node\\\\\"),\n                \"Path should contain 'node\\\\' on Windows: {path_str}\"\n            );\n        } else {\n            // On Unix, forward slashes are expected\n            assert!(path_str.contains(\"node/\"), \"Path should contain 'node/' on Unix: {path_str}\");\n        }\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_with_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with devEngines.runtime\n        let package_json = r#\"{\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"^20.18.0\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        // Version should be >= 20.18.0 and < 21.0.0\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n        assert!(parsed.minor >= 18);\n\n        // Verify the binary exists and works\n        let binary_path = runtime.get_binary_path();\n        assert!(tokio::fs::try_exists(&binary_path).await.unwrap());\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_with_multiple_runtimes() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with array of runtimes\n        let package_json = r#\"{\n            \"devEngines\": {\n                \"runtime\": [\n                    {\"name\": \"deno\", \"version\": \"^2.0.0\"},\n                    {\"name\": \"node\", \"version\": \"^20.18.0\"}\n                ]\n            }\n        }\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        // Should use node runtime (deno is not supported yet)\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_no_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json without devEngines (minified, will use default 2-space indent)\n        let package_json = r#\"{\"name\": \"test-project\"}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        // Should download Node.js\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n\n        // Should have a valid version\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert!(parsed.major >= 20);\n\n        // .node-version is written only if no ancestor has one (write-back is\n        // suppressed when an ancestor .node-version exists, e.g. in a monorepo)\n        if tokio::fs::try_exists(temp_path.join(\".node-version\")).await.unwrap() {\n            let node_version_content =\n                tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n            assert_eq!(node_version_content, format!(\"{version}\\n\"));\n        }\n\n        // package.json should remain unchanged\n        let pkg_content = tokio::fs::read_to_string(temp_path.join(\"package.json\")).await.unwrap();\n        assert_eq!(pkg_content, package_json);\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_does_not_write_back_when_no_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with runtime but no version\n        let package_json = r#\"{\n  \"name\": \"test-project\",\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\"\n    }\n  }\n}\n\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let _runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        // .node-version should NOT be written (auto-write was removed)\n        assert!(\n            !tokio::fs::try_exists(temp_path.join(\".node-version\")).await.unwrap(),\n            \".node-version should not be auto-created\"\n        );\n\n        // package.json should remain unchanged\n        let pkg_content = tokio::fs::read_to_string(temp_path.join(\"package.json\")).await.unwrap();\n        assert_eq!(pkg_content, package_json);\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_does_not_write_back_when_version_specified() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with version range\n        let package_json = r#\"{\n  \"name\": \"test-project\",\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"^20.18.0\"\n    }\n  }\n}\n\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n\n        // Should NOT write .node-version since a version was specified\n        assert!(!tokio::fs::try_exists(temp_path.join(\".node-version\")).await.unwrap());\n\n        // package.json should remain unchanged\n        let pkg_content = tokio::fs::read_to_string(temp_path.join(\"package.json\")).await.unwrap();\n        assert_eq!(pkg_content, package_json);\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_with_v_prefix_exact_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with exact version including 'v' prefix\n        let package_json = r#\"{\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"v20.18.0\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        // Version should be normalized (without 'v' prefix)\n        assert_eq!(runtime.version(), \"20.18.0\");\n\n        // Verify the binary exists and works\n        let binary_path = runtime.get_binary_path();\n        assert!(tokio::fs::try_exists(&binary_path).await.unwrap());\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_no_package_json() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // No package.json file\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        // Should download latest Node.js\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n\n        // Should NOT write .node-version\n        assert!(\n            !tokio::fs::try_exists(temp_path.join(\".node-version\")).await.unwrap(),\n            \".node-version should not be auto-created\"\n        );\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_inherits_parent_node_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Write .node-version in root (simulating monorepo root)\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // Create a sub-package directory with a minimal package.json (no engines/devEngines)\n        let subdir = temp_path.join(\"packages\").join(\"foo\");\n        tokio::fs::create_dir_all(&subdir).await.unwrap();\n        tokio::fs::write(subdir.join(\"package.json\"), r#\"{\"name\": \"foo\"}\"#).await.unwrap();\n\n        let runtime = download_runtime_for_project(&subdir).await.unwrap();\n\n        // Should inherit version from parent's .node-version\n        assert_eq!(runtime.version(), \"20.18.0\");\n\n        // Should NOT write .node-version in the sub-package\n        assert!(\n            !tokio::fs::try_exists(subdir.join(\".node-version\")).await.unwrap(),\n            \".node-version should not be written in sub-package when parent already has one\"\n        );\n    }\n\n    /// Integration test that downloads a real Node.js version\n    #[tokio::test]\n    async fn test_download_node_integration() {\n        // Use a small, old version for faster download\n        let version = \"20.18.0\";\n\n        let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap();\n\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        assert_eq!(runtime.version(), version);\n\n        // Verify the binary exists\n        let binary_path = runtime.get_binary_path();\n        assert!(tokio::fs::try_exists(&binary_path).await.unwrap());\n\n        // Verify binary is executable by checking version\n        let output = tokio::process::Command::new(binary_path.as_path())\n            .arg(\"--version\")\n            .output()\n            .await\n            .unwrap();\n\n        assert!(output.status.success());\n        let version_output = String::from_utf8_lossy(&output.stdout);\n        assert!(version_output.contains(version));\n    }\n\n    /// Test cache reuse - second call should be instant\n    #[tokio::test]\n    async fn test_download_node_cache_reuse() {\n        let version = \"20.18.0\";\n\n        // First download\n        let runtime1 = download_runtime(JsRuntimeType::Node, version).await.unwrap();\n\n        // Second download should use cache\n        let start = std::time::Instant::now();\n        let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap();\n        let elapsed = start.elapsed();\n\n        // Cache hit should be very fast (< 100ms)\n        assert!(elapsed.as_millis() < 100, \"Cache reuse took too long: {elapsed:?}\");\n\n        // Should return same install directory\n        assert_eq!(runtime1.install_dir, runtime2.install_dir);\n    }\n\n    /// Test that incomplete installations are cleaned up and re-downloaded\n    #[tokio::test]\n    #[ignore]\n    async fn test_incomplete_installation_cleanup() {\n        // Use a different version to avoid interference with other tests\n        let version = \"20.18.1\";\n\n        // First, ensure we have a valid cached version\n        let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap();\n        let install_dir = runtime.install_dir.clone();\n        let binary_path = runtime.get_binary_path();\n\n        // Simulate an incomplete installation by removing the binary but keeping the directory\n        tokio::fs::remove_file(&binary_path).await.unwrap();\n        assert!(!tokio::fs::try_exists(&binary_path).await.unwrap());\n        assert!(tokio::fs::try_exists(&install_dir).await.unwrap());\n\n        // Now download again - it should detect the incomplete installation and re-download\n        let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap();\n\n        // Verify the binary exists again\n        assert!(tokio::fs::try_exists(&runtime2.get_binary_path()).await.unwrap());\n\n        // Verify binary is executable\n        let output = tokio::process::Command::new(runtime2.get_binary_path().as_path())\n            .arg(\"--version\")\n            .output()\n            .await\n            .unwrap();\n        assert!(output.status.success());\n    }\n\n    /// Test concurrent downloads - multiple tasks downloading the same version\n    /// should not cause corruption or conflicts due to file-based locking\n    #[tokio::test]\n    #[ignore]\n    async fn test_concurrent_downloads() {\n        // Use a different version to avoid conflicts with other tests\n        let version = \"20.17.0\";\n\n        // Clear any existing cache for this version\n        let cache_dir = crate::cache::get_cache_dir().unwrap();\n        let install_dir = cache_dir.join(\"node\").join(version);\n        if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) {\n            tokio::fs::remove_dir_all(&install_dir).await.unwrap();\n        }\n\n        // Spawn multiple concurrent download tasks\n        let num_concurrent = 4;\n        let mut handles = Vec::with_capacity(num_concurrent);\n\n        for i in 0..num_concurrent {\n            let version = version.to_string();\n            handles.push(tokio::spawn(async move {\n                tracing::info!(\"Starting concurrent download task {i}\");\n                let result = download_runtime(JsRuntimeType::Node, &version).await;\n                tracing::info!(\"Completed concurrent download task {i}\");\n                result\n            }));\n        }\n\n        // Wait for all tasks and collect results\n        let mut results = Vec::with_capacity(num_concurrent);\n        for handle in handles {\n            results.push(handle.await.unwrap());\n        }\n\n        // All tasks should succeed\n        for (i, result) in results.iter().enumerate() {\n            assert!(result.is_ok(), \"Task {i} failed: {:?}\", result.as_ref().err());\n        }\n\n        // All tasks should return the same install directory\n        let first_install_dir = &results[0].as_ref().unwrap().install_dir;\n        for (i, result) in results.iter().enumerate().skip(1) {\n            assert_eq!(\n                &result.as_ref().unwrap().install_dir,\n                first_install_dir,\n                \"Task {i} has different install_dir\"\n            );\n        }\n\n        // Verify the binary works\n        let runtime = results.into_iter().next().unwrap().unwrap();\n        let binary_path = runtime.get_binary_path();\n        assert!(\n            tokio::fs::try_exists(&binary_path).await.unwrap(),\n            \"Binary should exist at {binary_path:?}\"\n        );\n\n        let output = tokio::process::Command::new(binary_path.as_path())\n            .arg(\"--version\")\n            .output()\n            .await\n            .unwrap();\n\n        assert!(output.status.success(), \"Binary should be executable\");\n        let version_output = String::from_utf8_lossy(&output.stdout);\n        assert!(\n            version_output.contains(version),\n            \"Version output should contain {version}, got: {version_output}\"\n        );\n    }\n\n    // ==========================================\n    // Multi-source version reading tests\n    // ==========================================\n\n    #[tokio::test]\n    async fn test_node_version_file_takes_priority() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with exact version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // Create package.json with engines.node (should be ignored)\n        let package_json = r#\"{\"engines\":{\"node\":\">=22.0.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        assert_eq!(runtime.version(), \"20.18.0\");\n\n        // Should NOT write back since .node-version had exact version\n        let node_version_content =\n            tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(node_version_content, \"20.18.0\\n\");\n    }\n\n    #[tokio::test]\n    async fn test_engines_node_takes_priority_over_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with both engines.node and devEngines.runtime\n        let package_json = r#\"{\n  \"engines\": {\"node\": \"^20.18.0\"},\n  \"devEngines\": {\"runtime\": {\"name\": \"node\", \"version\": \"^22.0.0\"}}\n}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        // Should use engines.node (^20.18.0), which will resolve to a 20.x version\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n    }\n\n    #[tokio::test]\n    async fn test_only_engines_node_source() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with only engines.node\n        let package_json = r#\"{\"engines\":{\"node\":\"^20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n\n        // Should NOT write .node-version since a version was specified\n        assert!(!tokio::fs::try_exists(temp_path.join(\".node-version\")).await.unwrap());\n    }\n\n    #[tokio::test]\n    async fn test_node_version_file_partial_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with partial version (two parts)\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18\\n\").await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        // Should resolve to a 20.18.x or higher version in 20.x line\n        assert_eq!(parsed.major, 20);\n        // Minor version should be at least 18\n        assert!(parsed.minor >= 18, \"Expected minor >= 18, got {}\", parsed.minor);\n\n        // Should NOT write back - .node-version already has a version specified\n        let node_version_content =\n            tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(node_version_content, \"20.18\\n\");\n    }\n\n    #[tokio::test]\n    async fn test_node_version_file_single_part_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with single-part version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20\\n\").await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        // Should resolve to a 20.x.x version\n        assert_eq!(parsed.major, 20);\n\n        // Should NOT write back - .node-version already has a version specified\n        let node_version_content =\n            tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(node_version_content, \"20\\n\");\n    }\n\n    #[test]\n    fn test_version_source_display() {\n        assert_eq!(VersionSource::NodeVersionFile.to_string(), \".node-version\");\n        assert_eq!(VersionSource::EnginesNode.to_string(), \"engines.node\");\n        assert_eq!(VersionSource::DevEnginesRuntime.to_string(), \"devEngines.runtime\");\n    }\n\n    // ==========================================\n    // Invalid version validation tests\n    // ==========================================\n\n    #[tokio::test]\n    async fn test_invalid_node_version_file_is_ignored() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with invalid version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"invalid\\n\").await.unwrap();\n\n        // Create package.json without any version\n        let package_json = r#\"{\"name\": \"test-project\"}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Should fall through to fetch latest LTS since .node-version is invalid\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n\n        // Should have a valid version (latest LTS)\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert!(parsed.major >= 20);\n    }\n\n    #[tokio::test]\n    async fn test_invalid_engines_node_is_ignored() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with invalid engines.node\n        let package_json = r#\"{\"engines\":{\"node\":\"invalid\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Should fall through to fetch latest LTS since engines.node is invalid\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n\n        // Should have a valid version (latest LTS)\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert!(parsed.major >= 20);\n    }\n\n    #[tokio::test]\n    async fn test_invalid_dev_engines_runtime_is_ignored() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with invalid devEngines.runtime version\n        let package_json = r#\"{\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"invalid\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Should fall through to fetch latest LTS since devEngines.runtime is invalid\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n\n        // Should have a valid version (latest LTS)\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert!(parsed.major >= 20);\n    }\n\n    #[tokio::test]\n    async fn test_invalid_node_version_file_falls_through_to_valid_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with invalid version\n        tokio::fs::write(temp_path.join(\".node-version\"), \"invalid\\n\").await.unwrap();\n\n        // Create package.json with valid engines.node\n        let package_json = r#\"{\"engines\":{\"node\":\"^20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Should use engines.node since .node-version is invalid\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n    }\n\n    #[tokio::test]\n    async fn test_invalid_engines_falls_through_to_valid_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with invalid engines.node but valid devEngines.runtime\n        let package_json = r#\"{\n  \"engines\": {\"node\": \"invalid\"},\n  \"devEngines\": {\"runtime\": {\"name\": \"node\", \"version\": \"^20.18.0\"}}\n}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // Should use devEngines.runtime since engines.node is invalid\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20);\n    }\n\n    #[test]\n    fn test_normalize_version_exact() {\n        let version = Str::from(\"20.18.0\");\n        assert_eq!(normalize_version(&version, \"test\"), Some(version.clone()));\n    }\n\n    #[test]\n    fn test_normalize_version_with_v_prefix() {\n        let version = Str::from(\"v20.18.0\");\n        assert_eq!(normalize_version(&version, \"test\"), Some(version.clone()));\n    }\n\n    #[test]\n    fn test_normalize_version_range() {\n        let version = Str::from(\"^20.18.0\");\n        assert_eq!(normalize_version(&version, \"test\"), Some(version.clone()));\n    }\n\n    #[test]\n    fn test_normalize_version_partial() {\n        // Partial versions like \"20\" or \"20.18\" should be valid as ranges\n        let version = Str::from(\"20\");\n        assert_eq!(normalize_version(&version, \"test\"), Some(version.clone()));\n\n        let version = Str::from(\"20.18\");\n        assert_eq!(normalize_version(&version, \"test\"), Some(version.clone()));\n    }\n\n    #[test]\n    fn test_normalize_version_invalid() {\n        let version = Str::from(\"invalid\");\n        assert_eq!(normalize_version(&version, \"test\"), None);\n\n        let version = Str::from(\"not-a-version\");\n        assert_eq!(normalize_version(&version, \"test\"), None);\n    }\n\n    #[test]\n    fn test_normalize_version_real_world_ranges() {\n        // Test various real-world version range formats\n        let valid_ranges = [\n            \">=18\",\n            \">=18 <21\",\n            \"^18.18.0\",\n            \"~20.11.1\",\n            \"18.x\",\n            \"20.*\",\n            \"18 || 20 || >=22\",\n            \">=16 <=20\",\n            \">=20.0.0-rc.0\",\n            \"*\",\n        ];\n\n        for range in valid_ranges {\n            let version = Str::from(range);\n            assert_eq!(\n                normalize_version(&version, \"test\"),\n                Some(version.clone()),\n                \"Expected '{range}' to be valid\"\n            );\n        }\n    }\n\n    #[test]\n    fn test_normalize_version_with_negation() {\n        // node-semver crate supports negation syntax\n        let version = Str::from(\">=18 !=19.0.0 <21\");\n        assert_eq!(\n            normalize_version(&version, \"test\"),\n            Some(version.clone()),\n            \"Expected '>=18 !=19.0.0 <21' to be valid\"\n        );\n    }\n\n    #[test]\n    fn test_normalize_version_with_whitespace() {\n        // Versions with leading/trailing whitespace are trimmed\n        let version = Str::from(\"   20  \");\n        assert_eq!(\n            normalize_version(&version, \"test\"),\n            Some(Str::from(\"20\")),\n            \"Expected '   20  ' to be trimmed to '20'\"\n        );\n\n        let version = Str::from(\"  v20.2.0   \");\n        assert_eq!(\n            normalize_version(&version, \"test\"),\n            Some(Str::from(\"v20.2.0\")),\n            \"Expected '  v20.2.0   ' to be trimmed to 'v20.2.0'\"\n        );\n    }\n\n    #[test]\n    fn test_normalize_version_empty_or_whitespace_only() {\n        let version = Str::from(\"\");\n        assert_eq!(normalize_version(&version, \"test\"), None);\n\n        let version = Str::from(\"   \");\n        assert_eq!(normalize_version(&version, \"test\"), None);\n    }\n\n    #[test]\n    fn test_normalize_version_lts_aliases() {\n        // LTS aliases should be accepted by normalize_version\n        assert_eq!(normalize_version(&\"lts/*\".into(), \".node-version\"), Some(\"lts/*\".into()));\n        assert_eq!(normalize_version(&\"lts/iron\".into(), \".node-version\"), Some(\"lts/iron\".into()));\n        assert_eq!(normalize_version(&\"lts/jod\".into(), \".node-version\"), Some(\"lts/jod\".into()));\n        assert_eq!(normalize_version(&\"lts/-1\".into(), \".node-version\"), Some(\"lts/-1\".into()));\n        assert_eq!(normalize_version(&\"lts/-2\".into(), \".node-version\"), Some(\"lts/-2\".into()));\n    }\n\n    #[test]\n    fn test_normalize_version_latest_alias() {\n        // \"latest\" alias should be accepted by normalize_version (case-insensitive)\n        assert_eq!(normalize_version(&\"latest\".into(), \".node-version\"), Some(\"latest\".into()));\n        assert_eq!(normalize_version(&\"Latest\".into(), \".node-version\"), Some(\"Latest\".into()));\n        assert_eq!(normalize_version(&\"LATEST\".into(), \".node-version\"), Some(\"LATEST\".into()));\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_with_lts_alias_in_node_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with LTS alias\n        tokio::fs::write(temp_path.join(\".node-version\"), \"lts/iron\\n\").await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        // lts/iron should resolve to v20.x\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert_eq!(parsed.major, 20, \"lts/iron should resolve to v20.x, got {version}\");\n\n        // Should NOT overwrite .node-version - user explicitly specified an LTS alias\n        let node_version_content =\n            tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(node_version_content, \"lts/iron\\n\", \".node-version should remain unchanged\");\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_with_lts_latest_alias() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with lts/* alias\n        tokio::fs::write(temp_path.join(\".node-version\"), \"lts/*\\n\").await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        // lts/* should resolve to latest LTS (at least v22.x as of 2026)\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        assert!(parsed.major >= 22, \"lts/* should resolve to at least v22.x, got {version}\");\n\n        // Should NOT overwrite .node-version\n        let node_version_content =\n            tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(node_version_content, \"lts/*\\n\", \".node-version should remain unchanged\");\n    }\n\n    #[tokio::test]\n    async fn test_download_runtime_for_project_with_latest_alias_in_node_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version with \"latest\" alias\n        tokio::fs::write(temp_path.join(\".node-version\"), \"latest\\n\").await.unwrap();\n\n        let runtime = download_runtime_for_project(&temp_path).await.unwrap();\n\n        assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);\n        // \"latest\" should resolve to the absolute latest version (including non-LTS)\n        let version = runtime.version();\n        let parsed = node_semver::Version::parse(version).unwrap();\n        // Latest version should be at least v20.x\n        assert!(parsed.major >= 20, \"'latest' should resolve to at least v20.x, got {version}\");\n\n        // Should NOT overwrite .node-version - user explicitly specified \"latest\"\n        let node_version_content =\n            tokio::fs::read_to_string(temp_path.join(\".node-version\")).await.unwrap();\n        assert_eq!(node_version_content, \"latest\\n\", \".node-version should remain unchanged\");\n    }\n\n    // ==========================================\n    // resolve_node_version tests\n    // ==========================================\n\n    #[tokio::test]\n    async fn test_resolve_node_version_no_walk_up() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version file\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap();\n        assert_eq!(&*resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, VersionSource::NodeVersionFile);\n        assert!(resolution.source_path.is_some());\n        assert!(resolution.project_root.is_some());\n    }\n\n    #[tokio::test]\n    async fn test_resolve_node_version_with_walk_up() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version in parent\n        tokio::fs::write(temp_path.join(\".node-version\"), \"20.18.0\\n\").await.unwrap();\n\n        // Create subdirectory\n        let subdir = temp_path.join(\"subdir\");\n        tokio::fs::create_dir(&subdir).await.unwrap();\n\n        // With walk_up=true, should find version in parent\n        let resolution = resolve_node_version(&subdir, true).await.unwrap().unwrap();\n        assert_eq!(&*resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, VersionSource::NodeVersionFile);\n\n        // With walk_up=false, should not find version\n        let resolution = resolve_node_version(&subdir, false).await.unwrap();\n        assert!(resolution.is_none());\n    }\n\n    #[tokio::test]\n    async fn test_resolve_node_version_engines_node() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with engines.node\n        let package_json = r#\"{\"engines\":{\"node\":\"20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap();\n        assert_eq!(&*resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, VersionSource::EnginesNode);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_node_version_dev_engines() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create package.json with devEngines.runtime\n        let package_json = r#\"{\"devEngines\":{\"runtime\":{\"name\":\"node\",\"version\":\"20.18.0\"}}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap();\n        assert_eq!(&*resolution.version, \"20.18.0\");\n        assert_eq!(resolution.source, VersionSource::DevEnginesRuntime);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_node_version_priority() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create both .node-version and package.json with different versions\n        tokio::fs::write(temp_path.join(\".node-version\"), \"22.0.0\\n\").await.unwrap();\n        let package_json = r#\"{\"engines\":{\"node\":\"20.18.0\"}}\"#;\n        tokio::fs::write(temp_path.join(\"package.json\"), package_json).await.unwrap();\n\n        let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap();\n        // .node-version should take priority\n        assert_eq!(&*resolution.version, \"22.0.0\");\n        assert_eq!(resolution.source, VersionSource::NodeVersionFile);\n    }\n\n    #[tokio::test]\n    async fn test_resolve_node_version_none_when_no_sources() {\n        let temp_dir = TempDir::new().unwrap();\n        let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // No version sources at all\n        let resolution = resolve_node_version(&temp_path, false).await.unwrap();\n        assert!(resolution.is_none());\n    }\n\n    /// Test that package.json in child directory takes priority over .node-version in parent.\n    ///\n    /// Directory structure:\n    /// ```\n    /// parent/\n    ///   .node-version (22.0.0)\n    ///   child/\n    ///     package.json (engines.node: \"20.18.0\")\n    /// ```\n    ///\n    /// When resolving from `child/` with walk_up=true, it should find `package.json` in child\n    /// (20.18.0) instead of `.node-version` in parent (22.0.0).\n    #[tokio::test]\n    async fn test_resolve_node_version_child_package_json_over_parent_node_version() {\n        let temp_dir = TempDir::new().unwrap();\n        let parent_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();\n\n        // Create .node-version in parent\n        tokio::fs::write(parent_path.join(\".node-version\"), \"22.0.0\\n\").await.unwrap();\n\n        // Create child directory with package.json\n        let child_path = parent_path.join(\"child\");\n        tokio::fs::create_dir(&child_path).await.unwrap();\n        let package_json = r#\"{\"engines\":{\"node\":\"20.18.0\"}}\"#;\n        tokio::fs::write(child_path.join(\"package.json\"), package_json).await.unwrap();\n\n        // When resolving from child with walk_up=true, should find package.json in child\n        // NOT the .node-version in parent\n        let resolution = resolve_node_version(&child_path, true).await.unwrap().unwrap();\n        assert_eq!(\n            &*resolution.version, \"20.18.0\",\n            \"Should use child's package.json (20.18.0), not parent's .node-version (22.0.0)\"\n        );\n        assert_eq!(resolution.source, VersionSource::EnginesNode);\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/Cargo.toml",
    "content": "[package]\nname = \"vite_migration\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\nrust-version.workspace = true\n\n[dependencies]\nast-grep-config = { workspace = true }\nast-grep-core = { workspace = true }\nast-grep-language = { workspace = true }\nbrush-parser = { workspace = true }\nignore = { workspace = true }\nregex = { workspace = true }\nserde_json = { workspace = true, features = [\"preserve_order\"] }\nvite_error = { workspace = true }\n\n[dev-dependencies]\ntempfile = { workspace = true }\n\n[lints]\nworkspace = true\n\n[lib]\ndoctest = false\n"
  },
  {
    "path": "crates/vite_migration/src/ast_grep.rs",
    "content": "use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string};\nuse ast_grep_core::replacer::Replacer;\nuse ast_grep_language::{LanguageExt, SupportLang};\nuse vite_error::Error;\n\n/// Apply ast-grep rules to content and return the transformed content\n///\n/// This is the core transformation function that:\n/// 1. Parses the rule YAML\n/// 2. Applies each rule to find matches\n/// 3. Replaces matches from back to front to maintain correct positions\n///\n/// # Arguments\n///\n/// * `content` - The source content to transform\n/// * `rule_yaml` - The ast-grep rules in YAML format\n///\n/// # Returns\n///\n/// A tuple of (transformed_content, was_updated)\npub(crate) fn apply_rules(content: &str, rule_yaml: &str) -> Result<(String, bool), Error> {\n    let rules = load_rules(rule_yaml)?;\n    let result = apply_loaded_rules(content, &rules);\n    let updated = result != content;\n    Ok((result, updated))\n}\n\n/// Load ast-grep rules from YAML string\npub(crate) fn load_rules(yaml: &str) -> Result<Vec<RuleConfig<SupportLang>>, Error> {\n    let globals = GlobalRules::default();\n    let rules: Vec<RuleConfig<SupportLang>> = from_yaml_string::<SupportLang>(yaml, &globals)?;\n    Ok(rules)\n}\n\n/// Apply pre-loaded ast-grep rules to content\n///\n/// This is useful when you need to apply the same rules multiple times\n/// (e.g., processing multiple scripts in a loop).\n///\n/// # Arguments\n///\n/// * `content` - The source content to transform\n/// * `rules` - Pre-loaded ast-grep rules\n///\n/// # Returns\n///\n/// The transformed content (always returns a new string, even if unchanged)\npub(crate) fn apply_loaded_rules(content: &str, rules: &[RuleConfig<SupportLang>]) -> String {\n    let mut current = content.to_string();\n\n    for rule in rules {\n        // Parse current content with the rule's language\n        let grep = rule.language.ast_grep(&current);\n        let root = grep.root();\n\n        let matcher = &rule.matcher;\n\n        // Get the fixer if available (rules without fix are pure lint, skip them)\n        let fixers = match rule.get_fixer() {\n            Ok(f) if !f.is_empty() => f,\n            _ => continue,\n        };\n\n        // Collect all matches and their replacements\n        let mut replacements = Vec::new();\n        for node in root.find_all(matcher) {\n            let range = node.range();\n            let replacement_bytes = fixers[0].generate_replacement(&node);\n            let replacement_str = String::from_utf8_lossy(&replacement_bytes).to_string();\n            replacements.push((range.start, range.end, replacement_str));\n        }\n\n        // Replace from back to front to maintain correct positions\n        replacements.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));\n\n        for (start, end, replacement) in replacements {\n            current.replace_range(start..end, &replacement);\n        }\n    }\n\n    current\n}\n"
  },
  {
    "path": "crates/vite_migration/src/eslint.rs",
    "content": "use crate::script_rewrite::{ScriptRewriteConfig, rewrite_script};\n\nconst ESLINT_CONFIG: ScriptRewriteConfig = ScriptRewriteConfig {\n    source_command: \"eslint\",\n    target_subcommand: \"lint\",\n    boolean_flags: &[\n        \"--cache\",\n        \"--no-eslintrc\",\n        \"--no-error-on-unmatched-pattern\",\n        \"--debug\",\n        \"--no-inline-config\",\n    ],\n    value_flags: &[\n        \"--ext\",\n        \"--rulesdir\",\n        \"--resolve-plugins-relative-to\",\n        \"--parser\",\n        \"--parser-options\",\n        \"--plugin\",\n        \"--output-file\",\n        \"--env\",\n    ],\n    flag_conversions: &[],\n};\n\n/// Rewrite a single script: rename `eslint` → `vp lint` and strip ESLint-only flags.\npub(crate) fn rewrite_eslint_script(script: &str) -> String {\n    rewrite_script(script, &ESLINT_CONFIG)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_rewrite_eslint_script() {\n        // Basic rename: eslint → vp lint\n        assert_eq!(rewrite_eslint_script(\"eslint .\"), \"vp lint .\");\n        assert_eq!(rewrite_eslint_script(\"eslint --fix .\"), \"vp lint --fix .\");\n        assert_eq!(rewrite_eslint_script(\"eslint\"), \"vp lint\");\n\n        // Flag stripping + rename combined\n        assert_eq!(rewrite_eslint_script(\"eslint --fix --ext .ts,.tsx .\"), \"vp lint --fix .\");\n        assert_eq!(rewrite_eslint_script(\"eslint --ext .ts .\"), \"vp lint .\");\n        assert_eq!(rewrite_eslint_script(\"eslint --rulesdir ./rules --fix .\"), \"vp lint --fix .\");\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --parser @typescript-eslint/parser --fix .\"),\n            \"vp lint --fix .\"\n        );\n        assert_eq!(rewrite_eslint_script(\"eslint --output-file report.txt .\"), \"vp lint .\");\n        assert_eq!(rewrite_eslint_script(\"eslint --env browser --fix .\"), \"vp lint --fix .\");\n\n        // value flags: --flag=value form\n        assert_eq!(rewrite_eslint_script(\"eslint --ext=.ts,.tsx .\"), \"vp lint .\");\n\n        // boolean flags\n        assert_eq!(rewrite_eslint_script(\"eslint --cache --fix .\"), \"vp lint --fix .\");\n        assert_eq!(rewrite_eslint_script(\"eslint --no-eslintrc --fix .\"), \"vp lint --fix .\");\n        assert_eq!(rewrite_eslint_script(\"eslint --debug .\"), \"vp lint .\");\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --no-error-on-unmatched-pattern --fix .\"),\n            \"vp lint --fix .\"\n        );\n        assert_eq!(rewrite_eslint_script(\"eslint --no-inline-config .\"), \"vp lint .\");\n\n        // multiple flags stripped at once\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --fix --ext .ts,.tsx --cache .\"),\n            \"vp lint --fix .\"\n        );\n\n        // edge case: value flag at end with no value\n        assert_eq!(rewrite_eslint_script(\"eslint --ext\"), \"vp lint\");\n\n        // compound: only eslint segments rewritten, other commands untouched\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --ext .ts . && vite build --debug\"),\n            \"vp lint . && vite build --debug\"\n        );\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --cache --fix . && other-tool --env production\"),\n            \"vp lint --fix . && other-tool --env production\"\n        );\n        assert_eq!(\n            rewrite_eslint_script(\"some-tool --cache && eslint --ext .ts .\"),\n            \"some-tool --cache && vp lint .\"\n        );\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --ext .ts . && eslint --cache --fix src/\"),\n            \"vp lint . && vp lint --fix src/\"\n        );\n        assert_eq!(rewrite_eslint_script(\"eslint . && vite build\"), \"vp lint . && vite build\");\n\n        // non-eslint commands pass through unchanged (no-op returns original exactly)\n        assert_eq!(rewrite_eslint_script(\"vp build\"), \"vp build\");\n        assert_eq!(rewrite_eslint_script(\"vp lint --cache --fix .\"), \"vp lint --cache --fix .\");\n        assert_eq!(rewrite_eslint_script(\"echo 'a |b'\"), \"echo 'a |b'\");\n\n        // pipe: only eslint segment rewritten, piped command untouched\n        assert_eq!(\n            rewrite_eslint_script(\"eslint --cache . | tee report.txt\"),\n            \"vp lint . | tee report.txt\"\n        );\n\n        // eslint with env var prefix\n        assert_eq!(\n            rewrite_eslint_script(\"NODE_ENV=test eslint --cache --ext .ts .\"),\n            \"NODE_ENV=test vp lint .\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_eslint_compound_commands() {\n        // subshell (brush-parser adds spaces inside parentheses)\n        assert_eq!(rewrite_eslint_script(\"(eslint --cache .)\"), \"( vp lint . )\");\n\n        // brace group: must have ; before }\n        assert_eq!(rewrite_eslint_script(\"{ eslint --cache .; }\"), \"{ vp lint .; }\");\n\n        // if clause: must have ; before fi\n        assert_eq!(\n            rewrite_eslint_script(\"if [ -f .eslintrc ]; then eslint --cache .; fi\"),\n            \"if [ -f .eslintrc ]; then vp lint .; fi\"\n        );\n\n        // while loop\n        assert_eq!(\n            rewrite_eslint_script(\"while true; do eslint .; done\"),\n            \"while true; do vp lint .; done\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_eslint_cross_env() {\n        // cross-env with eslint\n        assert_eq!(\n            rewrite_eslint_script(\"cross-env NODE_ENV=test eslint --cache --ext .ts .\"),\n            \"cross-env NODE_ENV=test vp lint .\"\n        );\n\n        // cross-env with eslint and --fix\n        assert_eq!(\n            rewrite_eslint_script(\"cross-env NODE_ENV=test eslint --cache --fix .\"),\n            \"cross-env NODE_ENV=test vp lint --fix .\"\n        );\n\n        // cross-env without eslint — passes through unchanged\n        assert_eq!(\n            rewrite_eslint_script(\"cross-env NODE_ENV=test jest\"),\n            \"cross-env NODE_ENV=test jest\"\n        );\n\n        // multiple env vars before eslint\n        assert_eq!(\n            rewrite_eslint_script(\"cross-env NODE_ENV=test CI=true eslint --cache .\"),\n            \"cross-env NODE_ENV=test CI=true vp lint .\"\n        );\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/src/file_walker.rs",
    "content": "use std::path::{Path, PathBuf};\n\nuse ignore::WalkBuilder;\nuse vite_error::Error;\n\n// TODO: only support esm files for now\n/// File extensions to process for import rewriting\nconst TS_JS_EXTENSIONS: &[&str] = &[\"ts\", \"tsx\", \"mts\", \"js\", \"jsx\", \"mjs\"];\n\n/// Result of walking TypeScript/JavaScript files\n#[derive(Debug)]\npub struct WalkResult {\n    /// List of file paths found\n    pub files: Vec<PathBuf>,\n}\n\n/// Find all TypeScript/JavaScript files in a directory, respecting gitignore\n///\n/// This function walks the directory tree starting from `root` and finds all files\n/// with TypeScript or JavaScript extensions (.ts, .tsx, .mts, .cts, .js, .jsx, .mjs, .cjs).\n///\n/// The walk respects:\n/// - `.gitignore` files in the directory tree\n/// - Global gitignore configuration\n/// - `.git/info/exclude` files\n/// - Hidden files and directories are skipped\n///\n/// # Arguments\n///\n/// * `root` - The root directory to start searching from\n///\n/// # Returns\n///\n/// Returns a `WalkResult` containing the list of found files, or an error if\n/// the directory walk fails.\n///\n/// # Example\n///\n/// ```ignore\n/// use std::path::Path;\n/// use vite_migration::find_ts_files;\n///\n/// let result = find_ts_files(Path::new(\"./src\"))?;\n/// for file in result.files {\n///     println!(\"Found: {}\", file.display());\n/// }\n/// ```\npub fn find_ts_files(root: &Path) -> Result<WalkResult, Error> {\n    let mut files = Vec::new();\n\n    let walker = WalkBuilder::new(root)\n        .hidden(true) // Skip hidden files/dirs\n        .git_ignore(true) // Respect .gitignore\n        .git_global(true) // Respect global gitignore\n        .git_exclude(true) // Respect .git/info/exclude\n        .require_git(false) // Work even if not a git repo\n        .build();\n\n    for entry in walker {\n        let entry = entry?;\n        let path = entry.path();\n\n        // Skip directories\n        if path.is_dir() {\n            continue;\n        }\n\n        // Check extension\n        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {\n            if TS_JS_EXTENSIONS.contains(&ext) {\n                files.push(path.to_path_buf());\n            }\n        }\n    }\n\n    Ok(WalkResult { files })\n}\n\n#[cfg(test)]\nmod tests {\n    use std::fs;\n\n    use tempfile::tempdir;\n\n    use super::*;\n\n    #[test]\n    fn test_find_ts_files_basic() {\n        let temp = tempdir().unwrap();\n\n        // Create test files\n        fs::write(temp.path().join(\"app.ts\"), \"\").unwrap();\n        fs::write(temp.path().join(\"utils.tsx\"), \"\").unwrap();\n        fs::write(temp.path().join(\"config.js\"), \"\").unwrap();\n        fs::write(temp.path().join(\"readme.md\"), \"\").unwrap();\n\n        let result = find_ts_files(temp.path()).unwrap();\n\n        // Should find ts, tsx, js but not md\n        assert_eq!(result.files.len(), 3);\n    }\n\n    #[test]\n    fn test_find_ts_files_nested() {\n        let temp = tempdir().unwrap();\n\n        // Create nested directory\n        fs::create_dir(temp.path().join(\"src\")).unwrap();\n        fs::write(temp.path().join(\"src/index.ts\"), \"\").unwrap();\n        fs::write(temp.path().join(\"src/utils.tsx\"), \"\").unwrap();\n\n        // Create deeper nesting\n        fs::create_dir_all(temp.path().join(\"src/components\")).unwrap();\n        fs::write(temp.path().join(\"src/components/Button.tsx\"), \"\").unwrap();\n\n        let result = find_ts_files(temp.path()).unwrap();\n\n        assert_eq!(result.files.len(), 3);\n    }\n\n    #[test]\n    fn test_find_ts_files_respects_gitignore() {\n        let temp = tempdir().unwrap();\n\n        // Create test files\n        fs::write(temp.path().join(\"app.ts\"), \"\").unwrap();\n\n        // Create node_modules (should be ignored via gitignore)\n        fs::create_dir(temp.path().join(\"node_modules\")).unwrap();\n        fs::write(temp.path().join(\"node_modules/pkg.ts\"), \"\").unwrap();\n\n        // Create dist (should be ignored via gitignore)\n        fs::create_dir(temp.path().join(\"dist\")).unwrap();\n        fs::write(temp.path().join(\"dist/bundle.js\"), \"\").unwrap();\n\n        // Create .gitignore\n        fs::write(temp.path().join(\".gitignore\"), \"node_modules/\\ndist/\").unwrap();\n\n        let result = find_ts_files(temp.path()).unwrap();\n\n        // Should only find app.ts, not node_modules or dist files\n        assert_eq!(result.files.len(), 1);\n        assert!(result.files[0].ends_with(\"app.ts\"));\n    }\n\n    #[test]\n    fn test_find_ts_files_all_extensions() {\n        let temp = tempdir().unwrap();\n\n        // Create files with all supported extensions\n        fs::write(temp.path().join(\"a.ts\"), \"\").unwrap();\n        fs::write(temp.path().join(\"b.tsx\"), \"\").unwrap();\n        fs::write(temp.path().join(\"c.mts\"), \"\").unwrap();\n        fs::write(temp.path().join(\"d.cts\"), \"\").unwrap();\n        fs::write(temp.path().join(\"e.js\"), \"\").unwrap();\n        fs::write(temp.path().join(\"f.jsx\"), \"\").unwrap();\n        fs::write(temp.path().join(\"g.mjs\"), \"\").unwrap();\n        fs::write(temp.path().join(\"h.cjs\"), \"\").unwrap();\n\n        // Create non-matching files\n        fs::write(temp.path().join(\"i.json\"), \"\").unwrap();\n        fs::write(temp.path().join(\"j.css\"), \"\").unwrap();\n        fs::write(temp.path().join(\"k.html\"), \"\").unwrap();\n\n        let result = find_ts_files(temp.path()).unwrap();\n\n        assert_eq!(result.files.len(), 6);\n    }\n\n    #[test]\n    fn test_find_ts_files_empty_directory() {\n        let temp = tempdir().unwrap();\n\n        let result = find_ts_files(temp.path()).unwrap();\n\n        assert!(result.files.is_empty());\n    }\n\n    #[test]\n    fn test_find_ts_files_skips_hidden() {\n        let temp = tempdir().unwrap();\n\n        // Create visible file\n        fs::write(temp.path().join(\"visible.ts\"), \"\").unwrap();\n\n        // Create hidden directory with ts file\n        fs::create_dir(temp.path().join(\".hidden\")).unwrap();\n        fs::write(temp.path().join(\".hidden/secret.ts\"), \"\").unwrap();\n\n        let result = find_ts_files(temp.path()).unwrap();\n\n        // Should only find visible.ts\n        assert_eq!(result.files.len(), 1);\n        assert!(result.files[0].ends_with(\"visible.ts\"));\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/src/import_rewriter.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::{Path, PathBuf},\n    sync::LazyLock,\n};\n\nuse regex::Regex;\nuse vite_error::Error;\n\nuse crate::{ast_grep, file_walker};\n\n/// ast-grep rules for rewriting vite imports and declare module statements\n///\n/// This rewrites:\n/// - `import { ... } from 'vite'` → `import { ... } from 'vite-plus'`\n/// - `import { ... } from 'vite/{name}'` → `import { ... } from 'vite-plus/{name}'`\n/// - `declare module 'vite' { ... }` → `declare module 'vite-plus' { ... }`\n/// - `declare module 'vite/{name}' { ... }` → `declare module 'vite-plus/{name}' { ... }`\nconst REWRITE_VITE_RULES: &str = r#\"---\nid: rewrite-vite-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]vite['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vite\n      by: \"vite-plus\"\nfix: $NEW_IMPORT\n---\nid: rewrite-vite-subpath-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]vite/.+['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vite/\n      by: \"vite-plus/\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-vite\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\\\"]vite['\\\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vite\n      by: \"vite-plus\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-vite-subpath\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\\\"]vite/.+['\\\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vite/\n      by: \"vite-plus/\"\nfix: $NEW_IMPORT\n\"#;\n\n/// ast-grep rules for rewriting vitest imports and declare module statements\n///\n/// This rewrites:\n/// - `import { ... } from 'vitest'` → `import { ... } from 'vite-plus/test'`\n/// - `import { ... } from 'vitest/config'` → `import { ... } from 'vite-plus'`\n/// - `import { ... } from 'vitest/{name}'` → `import { ... } from 'vite-plus/test/{name}'`\n/// - `import { ... } from '@vitest/browser'` → `import { ... } from 'vite-plus/test/browser'`\n/// - `import { ... } from '@vitest/browser/{name}'` → `import { ... } from 'vite-plus/test/browser/{name}'`\n/// - `import { ... } from '@vitest/browser-playwright'` → `import { ... } from 'vite-plus/test/browser-playwright'`\n/// - `import { ... } from '@vitest/browser-playwright/{name}'` → `import { ... } from 'vite-plus/test/browser-playwright/{name}'`\n/// - `import { ... } from '@vitest/browser-preview'` → `import { ... } from 'vite-plus/test/browser-preview'`\n/// - `import { ... } from '@vitest/browser-preview/{name}'` → `import { ... } from 'vite-plus/test/browser-preview/{name}'`\n/// - `import { ... } from '@vitest/browser-webdriverio'` → `import { ... } from 'vite-plus/test/browser-webdriverio'`\n/// - `import { ... } from '@vitest/browser-webdriverio/{name}'` → `import { ... } from 'vite-plus/test/browser-webdriverio/{name}'`\n/// - `declare module 'vitest' { ... }` → `declare module 'vite-plus/test' { ... }`\n/// - `declare module 'vitest/config' { ... }` → `declare module 'vite-plus' { ... }`\n/// - `declare module 'vitest/{name}' { ... }` → `declare module 'vite-plus/test/{name}' { ... }`\n/// - `declare module '@vitest/browser' { ... }` → `declare module 'vite-plus/test/browser' { ... }`\n/// - `declare module '@vitest/browser/{name}' { ... }` → `declare module 'vite-plus/test/browser/{name}' { ... }`\n/// - `declare module '@vitest/browser-playwright' { ... }` → `declare module 'vite-plus/test/browser-playwright' { ... }`\n/// - `declare module '@vitest/browser-playwright/{name}' { ... }` → `declare module 'vite-plus/test/browser-playwright/{name}' { ... }`\n/// - `declare module '@vitest/browser-preview' { ... }` → `declare module 'vite-plus/test/browser-preview' { ... }`\n/// - `declare module '@vitest/browser-preview/{name}' { ... }` → `declare module 'vite-plus/test/browser-preview/{name}' { ... }`\n/// - `declare module '@vitest/browser-webdriverio' { ... }` → `declare module 'vite-plus/test/browser-webdriverio' { ... }`\n/// - `declare module '@vitest/browser-webdriverio/{name}' { ... }` → `declare module 'vite-plus/test/browser-webdriverio/{name}' { ... }`\nconst REWRITE_VITEST_RULES: &str = r#\"---\nid: rewrite-vitest-config-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]vitest/config['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vitest/config\n      by: \"vite-plus\"\nfix: $NEW_IMPORT\n---\nid: rewrite-vitest-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]vitest['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vitest\n      by: \"vite-plus/test\"\nfix: $NEW_IMPORT\n---\nid: rewrite-vitest-scoped-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]@vitest/(browser-playwright|browser-preview|browser-webdriverio|browser)(/.*)?['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: \"@vitest/\"\n      by: \"vite-plus/test/\"\nfix: $NEW_IMPORT\n---\nid: rewrite-vitest-subpath-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]vitest/.+['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vitest/\n      by: \"vite-plus/test/\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-vitest-config\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\\\"]vitest/config['\\\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vitest/config\n      by: \"vite-plus\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-vitest\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\\\"]vitest['\\\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vitest\n      by: \"vite-plus/test\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-vitest-scoped\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\\\"]@vitest/(browser-playwright|browser-preview|browser-webdriverio|browser)(/.*)?['\\\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: \"@vitest/\"\n      by: \"vite-plus/test/\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-vitest-subpath\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\\\"]vitest/.+['\\\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: vitest/\n      by: \"vite-plus/test/\"\nfix: $NEW_IMPORT\n\"#;\n\n/// ast-grep rules for rewriting tsdown imports and declare module statements\n///\n/// This rewrites:\n/// - `import { ... } from 'tsdown'` → `import { ... } from 'vite-plus/pack'`\n/// - `declare module 'tsdown' { ... }` → `declare module 'vite-plus/pack' { ... }`\nconst REWRITE_TSDOWN_RULES: &str = r#\"---\nid: rewrite-tsdown-import\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]tsdown['\"]$\n  inside:\n    kind: import_statement\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: tsdown\n      by: \"vite-plus/pack\"\nfix: $NEW_IMPORT\n---\nid: rewrite-declare-module-tsdown\nlanguage: TypeScript\nrule:\n  pattern: $STR\n  kind: string\n  regex: ^['\"]tsdown['\"]$\n  inside:\n    kind: module\ntransform:\n  NEW_IMPORT:\n    replace:\n      source: $STR\n      replace: tsdown\n      by: \"vite-plus/pack\"\nfix: $NEW_IMPORT\n\"#;\n\n// Regex patterns for rewriting `/// <reference types=\"...\" />` directives.\n// These cannot be handled by ast-grep because triple-slash references are parsed as comments.\n\n/// `vitest/config` → `vite-plus` (special case, must be applied before generic vitest subpath)\nstatic RE_REF_VITEST_CONFIG: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])vitest/config([\"']\\s*/>)\"#).unwrap()\n});\n\n/// bare `vitest` → `vite-plus/test`\nstatic RE_REF_VITEST: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])vitest([\"']\\s*/>)\"#).unwrap()\n});\n\n/// `vitest/{subpath}` → `vite-plus/test/{subpath}`\nstatic RE_REF_VITEST_SUBPATH: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])vitest/(.+?)([\"']\\s*/>)\"#).unwrap()\n});\n\n/// `@vitest/{pkg}[/{subpath}]` → `vite-plus/test/{pkg}[/{subpath}]`\n/// Only matches packages and subpaths that vite-plus actually exports:\n///   - `@vitest/browser` → `vite-plus/test/browser`\n///   - `@vitest/browser/context` → `vite-plus/test/browser/context`\n///   - `@vitest/browser/providers/{name}` → `vite-plus/test/browser/providers/{name}`\n///   - `@vitest/browser-playwright[/{subpath}]` → `vite-plus/test/browser-playwright[/{subpath}]`\n///   - `@vitest/browser-preview[/{subpath}]` → `vite-plus/test/browser-preview[/{subpath}]`\n///   - `@vitest/browser-webdriverio[/{subpath}]` → `vite-plus/test/browser-webdriverio[/{subpath}]`\nstatic RE_REF_VITEST_SCOPED: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])@vitest/((?:browser-playwright|browser-preview|browser-webdriverio)(?:/.+?)?|browser(?:/(?:context|providers/.+?))?)([\"']\\s*/>)\"#).unwrap()\n});\n\n/// bare `vite` → `vite-plus`\nstatic RE_REF_VITE: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])vite([\"']\\s*/>)\"#).unwrap()\n});\n\n/// `vite/{subpath}` → `vite-plus/{subpath}`\nstatic RE_REF_VITE_SUBPATH: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])vite/(.+?)([\"']\\s*/>)\"#).unwrap()\n});\n\n/// bare `tsdown` → `vite-plus/pack`\nstatic RE_REF_TSDOWN: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r#\"^(\\s*///\\s*<reference\\s+types\\s*=\\s*[\"'])tsdown([\"']\\s*/>)\"#).unwrap()\n});\n\n/// Apply a single regex replacement, updating `content` in place if matched.\n/// Uses `Cow::Owned` variant check to avoid O(n) string comparison on no-match.\n/// Uses `replace` (not `replace_all`) since each line contains at most one reference directive.\nfn apply_regex_replace(content: &mut String, re: &Regex, replacement: &str) -> bool {\n    use std::borrow::Cow;\n    match re.replace(content, replacement) {\n        Cow::Owned(new) => {\n            *content = new;\n            true\n        }\n        Cow::Borrowed(_) => false,\n    }\n}\n\n/// Rewrite `/// <reference types=\"...\" />` directives in place.\n///\n/// Only processes the file preamble (blank lines and comments before the first statement)\n/// to match TypeScript semantics and avoid false positives inside string/template literals.\n/// Allocates only for preamble lines, leaving the file body untouched.\n/// Returns whether any changes were made.\nfn rewrite_reference_types(content: &mut String, skip_packages: &SkipPackages) -> bool {\n    // Fast path: skip files with no triple-slash reference directives.\n    // Check for \"///\" which covers all spacing variants (///<ref, /// <ref, ///\\t<ref).\n    if !content.contains(\"///\") {\n        return false;\n    }\n\n    // Find the byte offset where the preamble ends.\n    // TypeScript allows triple-slash directives after blank lines, single-line comments (//),\n    // block comments (/* ... */), a UTF-8 BOM, and a shebang line.\n    // The preamble ends at the first non-comment statement.\n    let bytes = content.as_bytes();\n    let mut preamble_end = 0;\n    let mut in_block_comment = false;\n\n    // Advance preamble_end past a line and its terminator (\\n or \\r\\n).\n    let advance_past_line = |offset: usize, line_len: usize| -> usize {\n        let mut pos = offset + line_len;\n        if pos < bytes.len() && bytes[pos] == b'\\r' {\n            pos += 1;\n        }\n        if pos < bytes.len() && bytes[pos] == b'\\n' {\n            pos += 1;\n        }\n        pos\n    };\n\n    // Check what follows after a `*/` close, scanning past any additional `/* ... */` pairs.\n    // Returns `None` if code follows (caller should break).\n    // Returns `Some(true)` if an unclosed `/*` follows (enter block comment).\n    // Returns `Some(false)` if the rest is empty, a `//` comment, or only closed block comments.\n    let check_after_close = |text: &str| -> Option<bool> {\n        let mut remaining = text.trim();\n        loop {\n            if remaining.is_empty() || remaining.starts_with(\"//\") {\n                return Some(false);\n            }\n            if !remaining.starts_with(\"/*\") {\n                return None; // Code follows — end of preamble.\n            }\n            // Another block comment starts — check if it closes on this line.\n            match remaining[2..].find(\"*/\") {\n                Some(pos) => remaining = remaining[2 + pos + 2..].trim(),\n                None => return Some(true), // Unclosed — enter block comment.\n            }\n        }\n    };\n\n    for line in content.lines() {\n        // Strip UTF-8 BOM (U+FEFF) before trimming — Rust's trim() does not remove BOM.\n        let trimmed = line.trim_start_matches('\\u{feff}').trim();\n        if in_block_comment {\n            if let Some(pos) = trimmed.find(\"*/\") {\n                match check_after_close(&trimmed[pos + 2..]) {\n                    None => break, // code after */ — end of preamble\n                    Some(new_block) => in_block_comment = new_block,\n                }\n            }\n            preamble_end = advance_past_line(preamble_end, line.len());\n            continue;\n        }\n        if trimmed.is_empty() || trimmed.starts_with(\"//\") || trimmed.starts_with(\"#!\") {\n            preamble_end = advance_past_line(preamble_end, line.len());\n            continue;\n        }\n        if trimmed.starts_with(\"/*\") {\n            if let Some(pos) = trimmed.find(\"*/\") {\n                match check_after_close(&trimmed[pos + 2..]) {\n                    None => break,\n                    Some(new_block) => in_block_comment = new_block,\n                }\n            } else {\n                in_block_comment = true;\n            }\n            preamble_end = advance_past_line(preamble_end, line.len());\n            continue;\n        }\n        break;\n    }\n\n    // Guard: unclosed block comment means the file has a syntax error; skip rewriting.\n    if in_block_comment {\n        return false;\n    }\n\n    let preamble = &content[..preamble_end];\n    // Check for \"///\" which covers all spacing variants (///<ref, /// <ref, etc.)\n    if !preamble.contains(\"///\") {\n        return false;\n    }\n\n    // Detect the line ending style used in the preamble for faithful reconstruction.\n    let line_ending = if preamble.contains(\"\\r\\n\") { \"\\r\\n\" } else { \"\\n\" };\n\n    let mut changed = false;\n    let mut preamble_lines: Vec<String> = preamble.lines().map(|l| l.to_string()).collect();\n    // Strip UTF-8 BOM from the first preamble line so the regex `^(\\s*///` can match.\n    if let Some(first) = preamble_lines.first_mut() {\n        if first.starts_with('\\u{feff}') {\n            *first = first.trim_start_matches('\\u{feff}').to_string();\n        }\n    }\n\n    for line in &mut preamble_lines {\n        // The regexes handle flexible spacing (///\\s*<reference), so just check for \"///\"\n        // to avoid filtering out valid variants like ///<reference or ///\\t<reference.\n        let trimmed = line.trim();\n        if trimmed.is_empty() || !trimmed.starts_with(\"///\") {\n            continue;\n        }\n        // Each line matches at most one pattern; use early exit to skip remaining regexes.\n        if !skip_packages.skip_vitest {\n            if apply_regex_replace(line, &RE_REF_VITEST_CONFIG, \"${1}vite-plus${2}\") {\n                changed = true;\n                continue;\n            }\n            if apply_regex_replace(line, &RE_REF_VITEST_SCOPED, \"${1}vite-plus/test/${2}${3}\") {\n                changed = true;\n                continue;\n            }\n            if apply_regex_replace(line, &RE_REF_VITEST_SUBPATH, \"${1}vite-plus/test/${2}${3}\") {\n                changed = true;\n                continue;\n            }\n            if apply_regex_replace(line, &RE_REF_VITEST, \"${1}vite-plus/test${2}\") {\n                changed = true;\n                continue;\n            }\n        }\n        if !skip_packages.skip_vite {\n            if apply_regex_replace(line, &RE_REF_VITE_SUBPATH, \"${1}vite-plus/${2}${3}\") {\n                changed = true;\n                continue;\n            }\n            if apply_regex_replace(line, &RE_REF_VITE, \"${1}vite-plus${2}\") {\n                changed = true;\n                continue;\n            }\n        }\n        if !skip_packages.skip_tsdown\n            && apply_regex_replace(line, &RE_REF_TSDOWN, \"${1}vite-plus/pack${2}\")\n        {\n            changed = true;\n        }\n    }\n\n    if changed {\n        let suffix = &content[preamble_end..];\n        let mut result = preamble_lines.join(line_ending);\n        // Re-add the line ending between preamble and suffix if the original had one\n        if preamble_end < content.len() || preamble.ends_with('\\n') {\n            result.push_str(line_ending);\n        }\n        result.push_str(suffix);\n        *content = result;\n    }\n\n    changed\n}\n\n/// Packages to skip rewriting based on peerDependencies or dependencies\n#[derive(Debug, Clone, Default)]\nstruct SkipPackages {\n    /// Skip rewriting vite imports (vite is in peerDependencies or dependencies)\n    skip_vite: bool,\n    /// Skip rewriting vitest imports (vitest is in peerDependencies or dependencies)\n    skip_vitest: bool,\n    /// Skip rewriting tsdown imports (tsdown is in peerDependencies or dependencies)\n    skip_tsdown: bool,\n}\n\nimpl SkipPackages {\n    /// Check if all packages should be skipped (file can be skipped entirely)\n    fn all_skipped(&self) -> bool {\n        self.skip_vite && self.skip_vitest && self.skip_tsdown\n    }\n}\n\n/// Find the nearest package.json by walking up from the file's directory.\n/// Stops at the root directory.\nfn find_nearest_package_json(file_path: &Path, root: &Path) -> Option<PathBuf> {\n    let mut current = file_path.parent()?;\n\n    loop {\n        let package_json = current.join(\"package.json\");\n        if package_json.exists() {\n            return Some(package_json);\n        }\n\n        // Stop if we've reached the root\n        if current == root {\n            break;\n        }\n\n        // Move to parent directory\n        current = current.parent()?;\n    }\n\n    None\n}\n\n/// Parse package.json and check which packages are in peerDependencies or dependencies.\n/// Returns default (no skipping) if package.json doesn't exist or can't be parsed.\nfn get_skip_packages_from_package_json(package_json_path: &Path) -> SkipPackages {\n    let content = match std::fs::read_to_string(package_json_path) {\n        Ok(c) => c,\n        Err(_) => return SkipPackages::default(),\n    };\n\n    let pkg: serde_json::Value = match serde_json::from_str(&content) {\n        Ok(p) => p,\n        Err(_) => return SkipPackages::default(),\n    };\n\n    // Helper to check if a package exists in a dependencies object\n    let has_package = |deps_key: &str, package_name: &str| -> bool {\n        pkg.get(deps_key)\n            .and_then(|v| v.as_object())\n            .map(|deps| deps.contains_key(package_name))\n            .unwrap_or(false)\n    };\n\n    // Check both peerDependencies and dependencies\n    SkipPackages {\n        skip_vite: has_package(\"peerDependencies\", \"vite\") || has_package(\"dependencies\", \"vite\"),\n        skip_vitest: has_package(\"peerDependencies\", \"vitest\")\n            || has_package(\"dependencies\", \"vitest\"),\n        skip_tsdown: has_package(\"peerDependencies\", \"tsdown\")\n            || has_package(\"dependencies\", \"tsdown\"),\n    }\n}\n\n/// Result of rewriting imports in a file\n#[derive(Debug)]\nstruct RewriteResult {\n    /// The updated file content\n    pub content: String,\n    /// Whether any changes were made\n    pub updated: bool,\n}\n\n/// Result of rewriting imports in multiple files\n#[derive(Debug)]\npub struct BatchRewriteResult {\n    /// Files that were modified\n    pub modified_files: Vec<PathBuf>,\n    /// Files that had no changes\n    pub unchanged_files: Vec<PathBuf>,\n    /// Files that had errors (path, error message)\n    pub errors: Vec<(PathBuf, String)>,\n}\n\n/// Rewrite imports in all TypeScript/JavaScript files under a directory\n///\n/// This function finds all TypeScript and JavaScript files in the specified directory\n/// (respecting `.gitignore` rules), applies the import rewrite rules to each file,\n/// and writes the modified content back to disk.\n///\n/// # Arguments\n///\n/// * `root` - The root directory to search for files\n///\n/// # Returns\n///\n/// Returns a `BatchRewriteResult` containing:\n/// - `modified_files`: Files that were changed\n/// - `unchanged_files`: Files that required no changes\n/// - `errors`: Files that had errors during processing\n///\n/// # Example\n///\n/// ```ignore\n/// use std::path::Path;\n/// use vite_migration::rewrite_imports_in_directory;\n///\n/// let result = rewrite_imports_in_directory(Path::new(\"./src\"))?;\n/// println!(\"Modified {} files\", result.modified_files.len());\n/// for file in &result.modified_files {\n///     println!(\"  {}\", file.display());\n/// }\n/// ```\npub fn rewrite_imports_in_directory(root: &Path) -> Result<BatchRewriteResult, Error> {\n    let walk_result = file_walker::find_ts_files(root)?;\n\n    let mut result = BatchRewriteResult {\n        modified_files: Vec::new(),\n        unchanged_files: Vec::new(),\n        errors: Vec::new(),\n    };\n\n    // Cache package.json lookups to avoid re-reading the same file\n    let mut skip_packages_cache: HashMap<PathBuf, SkipPackages> = HashMap::new();\n\n    for file_path in walk_result.files {\n        // Find the nearest package.json for this file\n        let skip_packages =\n            if let Some(package_json_path) = find_nearest_package_json(&file_path, root) {\n                skip_packages_cache\n                    .entry(package_json_path.clone())\n                    .or_insert_with(|| get_skip_packages_from_package_json(&package_json_path))\n                    .clone()\n            } else {\n                SkipPackages::default()\n            };\n\n        // If all packages are in peerDeps for this file's package, skip it\n        if skip_packages.all_skipped() {\n            result.unchanged_files.push(file_path);\n            continue;\n        }\n\n        match rewrite_import(&file_path, &skip_packages) {\n            Ok(rewrite_result) => {\n                if rewrite_result.updated {\n                    // Write the modified content back\n                    if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) {\n                        result.errors.push((file_path, e.to_string()));\n                    } else {\n                        result.modified_files.push(file_path);\n                    }\n                } else {\n                    result.unchanged_files.push(file_path);\n                }\n            }\n            Err(e) => {\n                result.errors.push((file_path, e.to_string()));\n            }\n        }\n    }\n\n    Ok(result)\n}\n\n/// Rewrite imports in a TypeScript/JavaScript file from vite/vitest to vite-plus\n///\n/// This function reads a file and rewrites the import statements\n/// to use 'vite-plus' instead of 'vite', 'vitest', or '@vitest/*'.\n/// Packages that are in peerDependencies or dependencies will be skipped.\n///\n/// # Arguments\n///\n/// * `file_path` - Path to the TypeScript/JavaScript file\n/// * `skip_packages` - Which packages to skip based on peerDependencies or dependencies\n///\n/// # Returns\n///\n/// Returns a `RewriteResult` containing:\n/// - `content`: The updated file content\n/// - `updated`: Whether any changes were made\nfn rewrite_import(file_path: &Path, skip_packages: &SkipPackages) -> Result<RewriteResult, Error> {\n    // Read the file\n    let content = std::fs::read_to_string(file_path)?;\n\n    // Rewrite the imports\n    rewrite_import_content(&content, skip_packages)\n}\n\n/// Rewrite imports in content from vite/vitest to vite-plus\n///\n/// This is the internal function that performs the actual rewrite using ast-grep.\n/// Packages that are in peerDependencies or dependencies will be skipped.\nfn rewrite_import_content(\n    content: &str,\n    skip_packages: &SkipPackages,\n) -> Result<RewriteResult, Error> {\n    let mut new_content = content.to_string();\n    let mut updated = false;\n\n    // Apply vite rules if not skipped\n    if !skip_packages.skip_vite {\n        let (vite_content, vite_updated) = ast_grep::apply_rules(&new_content, REWRITE_VITE_RULES)?;\n        if vite_updated {\n            new_content = vite_content;\n            updated = true;\n        }\n    }\n\n    // Apply vitest rules if not skipped\n    if !skip_packages.skip_vitest {\n        let (vitest_content, vitest_updated) =\n            ast_grep::apply_rules(&new_content, REWRITE_VITEST_RULES)?;\n        if vitest_updated {\n            new_content = vitest_content;\n            updated = true;\n        }\n    }\n\n    // Apply tsdown rules if not skipped\n    if !skip_packages.skip_tsdown {\n        let (tsdown_content, tsdown_updated) =\n            ast_grep::apply_rules(&new_content, REWRITE_TSDOWN_RULES)?;\n        if tsdown_updated {\n            new_content = tsdown_content;\n            updated = true;\n        }\n    }\n\n    // Apply reference type rewriting (/// <reference types=\"...\" />)\n    // These cannot be handled by ast-grep because they are parsed as comments.\n    updated |= rewrite_reference_types(&mut new_content, skip_packages);\n\n    Ok(RewriteResult { content: new_content, updated })\n}\n\n#[cfg(test)]\nmod tests {\n    use std::io::Write;\n\n    use tempfile::tempdir;\n\n    use super::*;\n\n    #[test]\n    fn test_rewrite_import_content_vite() {\n        let vite_config = r#\"import { defineConfig } from 'vite'\n\nexport default defineConfig({\n  plugins: [],\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus'\n\nexport default defineConfig({\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vite_double_quotes() {\n        let vite_config = r#\"import { defineConfig } from \"vite\";\n\nexport default defineConfig({\n  plugins: [],\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_config() {\n        let vite_config = r#\"import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_multiple_imports() {\n        let vite_config = r#\"import { defineConfig, loadEnv, type UserWorkspaceConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig, loadEnv, type UserWorkspaceConfig } from 'vite-plus';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_already_vite_plus() {\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, vite_config);\n    }\n\n    #[test]\n    fn test_rewrite_import_with_file() {\n        // Create temporary directory (automatically cleaned up when dropped)\n        let temp_dir = tempdir().unwrap();\n\n        let vite_config_path = temp_dir.path().join(\"vite.config.ts\");\n\n        // Write test vite config\n        let mut vite_file = std::fs::File::create(&vite_config_path).unwrap();\n        write!(\n            vite_file,\n            r#\"import {{ defineConfig }} from 'vite';\n\nexport default defineConfig({{\n  plugins: [],\n}});\"#\n        )\n        .unwrap();\n\n        // Run the rewrite\n        let result = rewrite_import(&vite_config_path, &SkipPackages::default()).unwrap();\n\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest() {\n        let vite_config = r#\"import { describe, it, expect } from 'vitest';\n\ndescribe('test', () => {\n  it('should work', () => {\n    expect(true).toBe(true);\n  });\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { describe, it, expect } from 'vite-plus/test';\n\ndescribe('test', () => {\n  it('should work', () => {\n    expect(true).toBe(true);\n  });\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_double_quotes() {\n        let vite_config = r#\"import { describe, it, expect } from \"vitest\";\n\ndescribe('test', () => {});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { describe, it, expect } from \"vite-plus/test\";\n\ndescribe('test', () => {});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser() {\n        let vite_config = r#\"import { page } from '@vitest/browser';\n\nexport default page;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { page } from 'vite-plus/test/browser';\n\nexport default page;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_double_quotes() {\n        let vite_config = r#\"import { page } from \"@vitest/browser\";\n\nexport default page;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { page } from \"vite-plus/test/browser\";\n\nexport default page;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_playwright() {\n        let vite_config = r#\"import { playwright } from '@vitest/browser-playwright';\n\nexport default playwright;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { playwright } from 'vite-plus/test/browser-playwright';\n\nexport default playwright;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_playwright_double_quotes() {\n        let vite_config = r#\"import { playwright } from \"@vitest/browser-playwright\";\n\nexport default playwright;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { playwright } from \"vite-plus/test/browser-playwright\";\n\nexport default playwright;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_subpath() {\n        let vite_config = r#\"import { context } from '@vitest/browser/context';\n\nexport default context;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { context } from 'vite-plus/test/browser/context';\n\nexport default context;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_playwright_subpath() {\n        let vite_config = r#\"import { something } from \"@vitest/browser-playwright/context\";\n\nexport default something;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { something } from \"vite-plus/test/browser-playwright/context\";\n\nexport default something;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_preview() {\n        let vite_config = r#\"import { preview } from '@vitest/browser-preview';\n\nexport default preview;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { preview } from 'vite-plus/test/browser-preview';\n\nexport default preview;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_preview_subpath() {\n        let vite_config = r#\"import { something } from \"@vitest/browser-preview/context\";\n\nexport default something;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { something } from \"vite-plus/test/browser-preview/context\";\n\nexport default something;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_webdriverio() {\n        let vite_config = r#\"import { webdriverio } from '@vitest/browser-webdriverio';\n\nexport default webdriverio;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { webdriverio } from 'vite-plus/test/browser-webdriverio';\n\nexport default webdriverio;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_browser_webdriverio_subpath() {\n        let vite_config = r#\"import { something } from \"@vitest/browser-webdriverio/context\";\n\nexport default something;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { something } from \"vite-plus/test/browser-webdriverio/context\";\n\nexport default something;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vite_subpath() {\n        let vite_config = r#\"import { ModuleRunner } from 'vite/module-runner';\n\nexport default ModuleRunner;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { ModuleRunner } from 'vite-plus/module-runner';\n\nexport default ModuleRunner;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vite_subpath_double_quotes() {\n        let vite_config = r#\"import { ModuleRunner } from \"vite/module-runner\";\n\nexport default ModuleRunner;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { ModuleRunner } from \"vite-plus/module-runner\";\n\nexport default ModuleRunner;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_subpath() {\n        // Test vitest/node subpath\n        let vite_config = r#\"import { startVitest } from 'vitest/node';\n\nexport default startVitest;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { startVitest } from 'vite-plus/test/node';\n\nexport default startVitest;\"#\n        );\n\n        // Test vitest/plugins/runner subpath\n        let vite_config = r#\"import { somePlugin } from 'vitest/plugins/runner';\n\nexport default somePlugin;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { somePlugin } from 'vite-plus/test/plugins/runner';\n\nexport default somePlugin;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_vitest_subpath_double_quotes() {\n        let vite_config = r#\"import { startVitest } from \"vitest/node\";\n\nexport default startVitest;\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { startVitest } from \"vite-plus/test/node\";\n\nexport default startVitest;\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_mixed_imports() {\n        // Test multiple different imports in the same file\n        let vite_config = r#\"import { defineConfig } from 'vite';\nimport { ModuleRunner } from 'vite/module-runner';\nimport { describe, it, expect } from 'vitest';\nimport { startVitest } from 'vitest/node';\nimport { page } from '@vitest/browser';\nimport { playwright } from '@vitest/browser-playwright';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n});\"#;\n\n        let result = rewrite_import_content(vite_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus';\nimport { ModuleRunner } from 'vite-plus/module-runner';\nimport { describe, it, expect } from 'vite-plus/test';\nimport { startVitest } from 'vite-plus/test/node';\nimport { page } from 'vite-plus/test/browser';\nimport { playwright } from 'vite-plus/test/browser-playwright';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_imports_in_directory() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create src directory\n        fs::create_dir(temp.path().join(\"src\")).unwrap();\n\n        // Create test files with vite/vitest imports\n        fs::write(\n            temp.path().join(\"src/config.ts\"),\n            r#\"import { defineConfig } from 'vite';\nexport default defineConfig({});\"#,\n        )\n        .unwrap();\n\n        fs::write(\n            temp.path().join(\"src/test.ts\"),\n            r#\"import { describe, it } from 'vitest';\ndescribe('test', () => {});\"#,\n        )\n        .unwrap();\n\n        // Create a file without vite imports (should be unchanged)\n        fs::write(\n            temp.path().join(\"src/utils.ts\"),\n            r#\"export function add(a: number, b: number) {\n  return a + b;\n}\"#,\n        )\n        .unwrap();\n\n        // Create node_modules (should be ignored)\n        fs::create_dir(temp.path().join(\"node_modules\")).unwrap();\n        fs::write(\n            temp.path().join(\"node_modules/pkg.ts\"),\n            r#\"import { defineConfig } from 'vite';\"#,\n        )\n        .unwrap();\n\n        // Create .gitignore\n        fs::write(temp.path().join(\".gitignore\"), \"node_modules/\").unwrap();\n\n        // Run the batch rewrite\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        // Should have 2 modified files (config.ts and test.ts)\n        assert_eq!(result.modified_files.len(), 2);\n        // Should have 1 unchanged file (utils.ts)\n        assert_eq!(result.unchanged_files.len(), 1);\n        // Should have no errors\n        assert!(result.errors.is_empty());\n\n        // Verify the files were actually modified\n        let config_content = fs::read_to_string(temp.path().join(\"src/config.ts\")).unwrap();\n        assert!(config_content.contains(\"vite-plus\"));\n\n        let test_content = fs::read_to_string(temp.path().join(\"src/test.ts\")).unwrap();\n        assert!(test_content.contains(\"vite-plus/test\"));\n\n        // Verify utils.ts was not modified\n        let utils_content = fs::read_to_string(temp.path().join(\"src/utils.ts\")).unwrap();\n        assert!(!utils_content.contains(\"vite-plus\"));\n    }\n\n    #[test]\n    fn test_rewrite_imports_in_directory_empty() {\n        let temp = tempdir().unwrap();\n\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        assert!(result.modified_files.is_empty());\n        assert!(result.unchanged_files.is_empty());\n        assert!(result.errors.is_empty());\n    }\n\n    #[test]\n    fn test_rewrite_imports_in_directory_nested() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create nested directory structure\n        fs::create_dir_all(temp.path().join(\"src/components/Button\")).unwrap();\n        fs::create_dir_all(temp.path().join(\"tests/unit\")).unwrap();\n\n        // Create files at various depths\n        fs::write(\n            temp.path().join(\"vite.config.ts\"),\n            r#\"import { defineConfig } from 'vite';\nexport default defineConfig({});\"#,\n        )\n        .unwrap();\n\n        fs::write(\n            temp.path().join(\"src/index.ts\"),\n            r#\"import { createServer } from 'vite';\nexport { createServer };\"#,\n        )\n        .unwrap();\n\n        fs::write(\n            temp.path().join(\"src/components/Button/Button.tsx\"),\n            r#\"import React from 'react';\nexport const Button = () => <button>Click</button>;\"#,\n        )\n        .unwrap();\n\n        fs::write(\n            temp.path().join(\"tests/unit/app.test.ts\"),\n            r#\"import { describe, it, expect } from 'vitest';\nimport { page } from '@vitest/browser';\n\ndescribe('app', () => {\n  it('works', () => {\n    expect(true).toBe(true);\n  });\n});\"#,\n        )\n        .unwrap();\n\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        // vite.config.ts, src/index.ts, tests/unit/app.test.ts should be modified\n        assert_eq!(result.modified_files.len(), 3);\n        // Button.tsx has no vite imports\n        assert_eq!(result.unchanged_files.len(), 1);\n        assert!(result.errors.is_empty());\n\n        // Verify nested file was modified\n        let test_content = fs::read_to_string(temp.path().join(\"tests/unit/app.test.ts\")).unwrap();\n        assert!(test_content.contains(\"vite-plus/test\"));\n        assert!(test_content.contains(\"vite-plus/test/browser\"));\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vite() {\n        let content = r#\"declare module 'vite' {\n  interface UserConfig {\n    runtimeEnv?: RuntimeEnvConfig;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus' {\n  interface UserConfig {\n    runtimeEnv?: RuntimeEnvConfig;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vite_double_quotes() {\n        let content = r#\"declare module \"vite\" {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module \"vite-plus\" {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest() {\n        let content = r#\"declare module 'vitest' {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test' {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_config() {\n        let content = r#\"declare module 'vitest/config' {\n  interface UserConfig {\n    test?: TestConfig;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus' {\n  interface UserConfig {\n    test?: TestConfig;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vite_subpath() {\n        let content = r#\"declare module 'vite/module-runner' {\n  export interface ModuleRunnerOptions {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/module-runner' {\n  export interface ModuleRunnerOptions {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_subpath() {\n        let content = r#\"declare module 'vitest/node' {\n  export interface VitestOptions {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/node' {\n  export interface VitestOptions {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser() {\n        let content = r#\"declare module '@vitest/browser' {\n  interface BrowserContext {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser' {\n  interface BrowserContext {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_subpath() {\n        let content = r#\"declare module '@vitest/browser/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_playwright() {\n        let content = r#\"declare module '@vitest/browser-playwright' {\n  interface PlaywrightContext {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser-playwright' {\n  interface PlaywrightContext {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_preview() {\n        let content = r#\"declare module '@vitest/browser-preview' {\n  interface PreviewContext {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser-preview' {\n  interface PreviewContext {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_webdriverio() {\n        let content = r#\"declare module '@vitest/browser-webdriverio' {\n  interface WebDriverContext {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser-webdriverio' {\n  interface WebDriverContext {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_mixed_imports_and_declare_modules() {\n        let content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\n\ndeclare module 'vite' {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\n\ndeclare module 'vitest' {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\n\nexport default defineConfig({});\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus';\nimport { describe } from 'vite-plus/test';\n\ndeclare module 'vite-plus' {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\n\ndeclare module 'vite-plus/test' {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\n\nexport default defineConfig({});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_already_vite_plus() {\n        let content = r#\"declare module 'vite-plus' {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_multiple_declare_modules() {\n        let content = r#\"declare module 'vite' {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\n\ndeclare module 'vite/module-runner' {\n  export interface ModuleRunnerOptions {\n    custom?: boolean;\n  }\n}\n\ndeclare module 'vitest' {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\n\ndeclare module '@vitest/browser' {\n  interface BrowserContext {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus' {\n  interface UserConfig {\n    custom?: boolean;\n  }\n}\n\ndeclare module 'vite-plus/module-runner' {\n  export interface ModuleRunnerOptions {\n    custom?: boolean;\n  }\n}\n\ndeclare module 'vite-plus/test' {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\n\ndeclare module 'vite-plus/test/browser' {\n  interface BrowserContext {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_double_quotes() {\n        let content = r#\"declare module \"vitest\" {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module \"vite-plus/test\" {\n  interface JestAssertion<T = any> {\n    toBeCustom(): void;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_playwright_subpath() {\n        let content = r#\"declare module '@vitest/browser-playwright/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser-playwright/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_preview_subpath() {\n        let content = r#\"declare module '@vitest/browser-preview/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser-preview/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_vitest_browser_webdriverio_subpath() {\n        let content = r#\"declare module '@vitest/browser-webdriverio/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/test/browser-webdriverio/context' {\n  export interface Context {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_complex_interface() {\n        let content = r#\"declare module 'vite' {\n  interface UserConfig {\n    /**\n     * Options for vite-plugin-runtime-env\n     */\n    runtimeEnv?: RuntimeEnvConfig;\n    /**\n     * Options for vite-plugin-runtime-html\n     */\n    runtimeHtml?: RuntimeHtmlConfig;\n  }\n\n  interface Plugin {\n    name: string;\n    configResolved?: (config: ResolvedConfig) => void;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus' {\n  interface UserConfig {\n    /**\n     * Options for vite-plugin-runtime-env\n     */\n    runtimeEnv?: RuntimeEnvConfig;\n    /**\n     * Options for vite-plugin-runtime-html\n     */\n    runtimeHtml?: RuntimeHtmlConfig;\n  }\n\n  interface Plugin {\n    name: string;\n    configResolved?: (config: ResolvedConfig) => void;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_tsdown() {\n        let tsdown_config = r#\"import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n  entry: 'src/index.ts',\n});\"#;\n\n        let result = rewrite_import_content(tsdown_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus/pack';\n\nexport default defineConfig({\n  entry: 'src/index.ts',\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_import_content_tsdown_double_quotes() {\n        let tsdown_config = r#\"import { defineConfig } from \"tsdown\";\n\nexport default defineConfig({\n  entry: \"src/index.ts\",\n});\"#;\n\n        let result = rewrite_import_content(tsdown_config, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from \"vite-plus/pack\";\n\nexport default defineConfig({\n  entry: \"src/index.ts\",\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_tsdown() {\n        let content = r#\"declare module 'tsdown' {\n  interface BuildConfig {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module 'vite-plus/pack' {\n  interface BuildConfig {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_declare_module_tsdown_double_quotes() {\n        let content = r#\"declare module \"tsdown\" {\n  interface BuildConfig {\n    custom?: boolean;\n  }\n}\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"declare module \"vite-plus/pack\" {\n  interface BuildConfig {\n    custom?: boolean;\n  }\n}\"#\n        );\n    }\n\n    // ========================\n    // PeerDependencies Tests\n    // ========================\n\n    #[test]\n    fn test_skip_vite_when_peer_dependency() {\n        // When vite is a peerDependency, vite imports should NOT be rewritten\n        let content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\n\nexport default defineConfig({});\"#;\n\n        let skip_packages =\n            SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: false };\n\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(result.updated);\n        // vite import should NOT be rewritten, vitest import SHOULD be rewritten\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vite-plus/test';\n\nexport default defineConfig({});\"#\n        );\n    }\n\n    #[test]\n    fn test_skip_vitest_when_peer_dependency() {\n        // When vitest is a peerDependency, vitest imports should NOT be rewritten\n        let content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\n\nexport default defineConfig({});\"#;\n\n        let skip_packages =\n            SkipPackages { skip_vite: false, skip_vitest: true, skip_tsdown: false };\n\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(result.updated);\n        // vite import SHOULD be rewritten, vitest import should NOT be rewritten\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite-plus';\nimport { describe } from 'vitest';\n\nexport default defineConfig({});\"#\n        );\n    }\n\n    #[test]\n    fn test_skip_all_when_all_peer_dependencies() {\n        // When all packages are peerDependencies, nothing should be rewritten\n        let content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\nimport { build } from 'tsdown';\n\nexport default defineConfig({});\"#;\n\n        let skip_packages = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true };\n\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_skip_packages_all_skipped() {\n        let skip_all = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true };\n        assert!(skip_all.all_skipped());\n\n        let skip_some = SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: true };\n        assert!(!skip_some.all_skipped());\n\n        let skip_none = SkipPackages::default();\n        assert!(!skip_none.all_skipped());\n    }\n\n    #[test]\n    fn test_get_skip_packages_from_package_json_with_vite_peer_dep() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create package.json with vite as peerDependency\n        let pkg_json = r#\"{\n  \"name\": \"my-vite-plugin\",\n  \"peerDependencies\": {\n    \"vite\": \"^5.0.0\"\n  }\n}\"#;\n        let package_json_path = temp.path().join(\"package.json\");\n        fs::write(&package_json_path, pkg_json).unwrap();\n\n        let skip = get_skip_packages_from_package_json(&package_json_path);\n        assert!(skip.skip_vite);\n        assert!(!skip.skip_vitest);\n        assert!(!skip.skip_tsdown);\n    }\n\n    #[test]\n    fn test_get_skip_packages_from_package_json_with_all_peer_deps() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        let pkg_json = r#\"{\n  \"name\": \"my-plugin\",\n  \"peerDependencies\": {\n    \"vite\": \"^5.0.0\",\n    \"vitest\": \"^1.0.0\",\n    \"tsdown\": \"^1.0.0\"\n  }\n}\"#;\n        let package_json_path = temp.path().join(\"package.json\");\n        fs::write(&package_json_path, pkg_json).unwrap();\n\n        let skip = get_skip_packages_from_package_json(&package_json_path);\n        assert!(skip.skip_vite);\n        assert!(skip.skip_vitest);\n        assert!(skip.skip_tsdown);\n        assert!(skip.all_skipped());\n    }\n\n    #[test]\n    fn test_get_skip_packages_from_package_json_with_vite_dependency() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // vite in dependencies should also skip rewriting\n        let pkg_json = r#\"{\n  \"name\": \"my-app\",\n  \"dependencies\": {\n    \"vite\": \"^5.0.0\"\n  }\n}\"#;\n        let package_json_path = temp.path().join(\"package.json\");\n        fs::write(&package_json_path, pkg_json).unwrap();\n\n        let skip = get_skip_packages_from_package_json(&package_json_path);\n        assert!(skip.skip_vite); // NOW skips because vite is in dependencies\n        assert!(!skip.skip_vitest);\n        assert!(!skip.skip_tsdown);\n    }\n\n    #[test]\n    fn test_get_skip_packages_from_package_json_no_file() {\n        let temp = tempdir().unwrap();\n\n        // No package.json created - should return default (no skipping)\n        let package_json_path = temp.path().join(\"package.json\");\n        let skip = get_skip_packages_from_package_json(&package_json_path);\n        assert!(!skip.skip_vite);\n        assert!(!skip.skip_vitest);\n        assert!(!skip.skip_tsdown);\n    }\n\n    #[test]\n    fn test_get_skip_packages_from_package_json_no_deps() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Package with no dependencies at all\n        let pkg_json = r#\"{\n  \"name\": \"my-app\"\n}\"#;\n        let package_json_path = temp.path().join(\"package.json\");\n        fs::write(&package_json_path, pkg_json).unwrap();\n\n        let skip = get_skip_packages_from_package_json(&package_json_path);\n        assert!(!skip.skip_vite);\n        assert!(!skip.skip_vitest);\n        assert!(!skip.skip_tsdown);\n    }\n\n    #[test]\n    fn test_get_skip_packages_mixed_peer_and_regular_deps() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // vite in dependencies, vitest in peerDependencies\n        let pkg_json = r#\"{\n  \"name\": \"my-package\",\n  \"dependencies\": {\n    \"vite\": \"^5.0.0\"\n  },\n  \"peerDependencies\": {\n    \"vitest\": \"^1.0.0\"\n  }\n}\"#;\n        let package_json_path = temp.path().join(\"package.json\");\n        fs::write(&package_json_path, pkg_json).unwrap();\n\n        let skip = get_skip_packages_from_package_json(&package_json_path);\n        assert!(skip.skip_vite); // in dependencies\n        assert!(skip.skip_vitest); // in peerDependencies\n        assert!(!skip.skip_tsdown);\n    }\n\n    #[test]\n    fn test_rewrite_imports_in_directory_with_vite_dependency() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create package.json with vite as dependency (not peerDependency)\n        let pkg_json = r#\"{\n  \"name\": \"my-app\",\n  \"dependencies\": {\n    \"vite\": \"^5.0.0\"\n  }\n}\"#;\n        fs::write(temp.path().join(\"package.json\"), pkg_json).unwrap();\n\n        // Create src directory\n        fs::create_dir(temp.path().join(\"src\")).unwrap();\n\n        // Create source file with vite and vitest imports\n        let original_content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\n\nexport default defineConfig({});\"#;\n        fs::write(temp.path().join(\"src/index.ts\"), original_content).unwrap();\n\n        // Run the batch rewrite\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        // File should be modified (vitest was rewritten)\n        assert_eq!(result.modified_files.len(), 1);\n        assert!(result.errors.is_empty());\n\n        // Verify vite import NOT rewritten (in dependencies), vitest IS rewritten\n        let content = fs::read_to_string(temp.path().join(\"src/index.ts\")).unwrap();\n        assert_eq!(\n            content,\n            r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vite-plus/test';\n\nexport default defineConfig({});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_imports_in_directory_with_peer_deps() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create package.json with vite as peerDependency\n        let pkg_json = r#\"{\n  \"name\": \"my-vite-plugin\",\n  \"peerDependencies\": {\n    \"vite\": \"^5.0.0\"\n  }\n}\"#;\n        fs::write(temp.path().join(\"package.json\"), pkg_json).unwrap();\n\n        // Create src directory\n        fs::create_dir(temp.path().join(\"src\")).unwrap();\n\n        // Create source file with vite and vitest imports\n        let original_content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\n\nexport default defineConfig({});\"#;\n        fs::write(temp.path().join(\"src/index.ts\"), original_content).unwrap();\n\n        // Run the batch rewrite\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        // File should be modified (vitest was rewritten)\n        assert_eq!(result.modified_files.len(), 1);\n        assert!(result.errors.is_empty());\n\n        // Verify vite import NOT rewritten, vitest IS rewritten\n        let content = fs::read_to_string(temp.path().join(\"src/index.ts\")).unwrap();\n        assert_eq!(\n            content,\n            r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vite-plus/test';\n\nexport default defineConfig({});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_imports_skips_file_when_all_peer_deps() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create package.json with all packages as peerDependencies\n        let pkg_json = r#\"{\n  \"name\": \"my-plugin\",\n  \"peerDependencies\": {\n    \"vite\": \"^5.0.0\",\n    \"vitest\": \"^1.0.0\",\n    \"tsdown\": \"^1.0.0\"\n  }\n}\"#;\n        fs::write(temp.path().join(\"package.json\"), pkg_json).unwrap();\n\n        // Create source file\n        let original_content = r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\nimport { build } from 'tsdown';\"#;\n        fs::write(temp.path().join(\"index.ts\"), original_content).unwrap();\n\n        // Run the batch rewrite\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        // File should be unchanged (all skipped)\n        assert!(result.modified_files.is_empty());\n        assert_eq!(result.unchanged_files.len(), 1);\n\n        // Verify content unchanged\n        let content = fs::read_to_string(temp.path().join(\"index.ts\")).unwrap();\n        assert_eq!(content, original_content);\n    }\n\n    #[test]\n    fn test_find_nearest_package_json() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create monorepo structure\n        fs::create_dir_all(temp.path().join(\"packages/vite-plugin/src\")).unwrap();\n        fs::create_dir_all(temp.path().join(\"packages/app/src\")).unwrap();\n\n        // Root package.json (no peerDeps)\n        fs::write(temp.path().join(\"package.json\"), r#\"{\"name\": \"monorepo\"}\"#).unwrap();\n\n        // vite-plugin package.json (has vite in peerDeps)\n        fs::write(\n            temp.path().join(\"packages/vite-plugin/package.json\"),\n            r#\"{\"name\": \"vite-plugin\", \"peerDependencies\": {\"vite\": \"^5.0.0\"}}\"#,\n        )\n        .unwrap();\n\n        // app package.json (no peerDeps)\n        fs::write(temp.path().join(\"packages/app/package.json\"), r#\"{\"name\": \"app\"}\"#).unwrap();\n\n        // Test finding package.json from vite-plugin/src/index.ts\n        let file_path = temp.path().join(\"packages/vite-plugin/src/index.ts\");\n        let result = find_nearest_package_json(&file_path, temp.path());\n        assert_eq!(result, Some(temp.path().join(\"packages/vite-plugin/package.json\")));\n\n        // Test finding package.json from app/src/index.ts\n        let file_path = temp.path().join(\"packages/app/src/index.ts\");\n        let result = find_nearest_package_json(&file_path, temp.path());\n        assert_eq!(result, Some(temp.path().join(\"packages/app/package.json\")));\n\n        // Test finding package.json from root level file\n        let file_path = temp.path().join(\"vite.config.ts\");\n        let result = find_nearest_package_json(&file_path, temp.path());\n        assert_eq!(result, Some(temp.path().join(\"package.json\")));\n    }\n\n    #[test]\n    fn test_rewrite_imports_monorepo_different_peer_deps() {\n        use std::fs;\n\n        let temp = tempdir().unwrap();\n\n        // Create monorepo structure\n        fs::create_dir_all(temp.path().join(\"packages/vite-plugin/src\")).unwrap();\n        fs::create_dir_all(temp.path().join(\"packages/app/src\")).unwrap();\n\n        // Root package.json (no peerDeps)\n        fs::write(temp.path().join(\"package.json\"), r#\"{\"name\": \"monorepo\"}\"#).unwrap();\n\n        // vite-plugin package.json (has vite in peerDeps)\n        fs::write(\n            temp.path().join(\"packages/vite-plugin/package.json\"),\n            r#\"{\"name\": \"vite-plugin\", \"peerDependencies\": {\"vite\": \"^5.0.0\"}}\"#,\n        )\n        .unwrap();\n\n        // app package.json (no peerDeps)\n        fs::write(temp.path().join(\"packages/app/package.json\"), r#\"{\"name\": \"app\"}\"#).unwrap();\n\n        // vite-plugin source file with vite and vitest imports\n        fs::write(\n            temp.path().join(\"packages/vite-plugin/src/index.ts\"),\n            r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\nexport default defineConfig({});\"#,\n        )\n        .unwrap();\n\n        // app source file with vite and vitest imports\n        fs::write(\n            temp.path().join(\"packages/app/src/index.ts\"),\n            r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vitest';\nexport default defineConfig({});\"#,\n        )\n        .unwrap();\n\n        // Run the batch rewrite\n        let result = rewrite_imports_in_directory(temp.path()).unwrap();\n\n        // Both files should be modified\n        assert_eq!(result.modified_files.len(), 2);\n\n        // vite-plugin: vite NOT rewritten (has peerDep), vitest IS rewritten\n        let vite_plugin_content =\n            fs::read_to_string(temp.path().join(\"packages/vite-plugin/src/index.ts\")).unwrap();\n        assert_eq!(\n            vite_plugin_content,\n            r#\"import { defineConfig } from 'vite';\nimport { describe } from 'vite-plus/test';\nexport default defineConfig({});\"#\n        );\n\n        // app: vite IS rewritten (no peerDep), vitest IS rewritten\n        let app_content =\n            fs::read_to_string(temp.path().join(\"packages/app/src/index.ts\")).unwrap();\n        assert_eq!(\n            app_content,\n            r#\"import { defineConfig } from 'vite-plus';\nimport { describe } from 'vite-plus/test';\nexport default defineConfig({});\"#\n        );\n    }\n\n    // ====================================\n    // Reference Types Rewriting Tests\n    // ====================================\n\n    #[test]\n    fn test_rewrite_reference_types_vite_client() {\n        let content = r#\"/// <reference types=\"vite/client\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus/client\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vite_client_single_quotes() {\n        let content = r#\"/// <reference types='vite/client' />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types='vite-plus/client' />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_bare_vite() {\n        let content = r#\"/// <reference types=\"vite\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_bare_vitest() {\n        let content = r#\"/// <reference types=\"vitest\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus/test\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_globals() {\n        let content = r#\"/// <reference types=\"vitest/globals\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus/test/globals\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_config() {\n        let content = r#\"/// <reference types=\"vitest/config\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_browser() {\n        let content = r#\"/// <reference types=\"vitest/browser\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus/test/browser\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_scoped_browser_matchers_not_rewritten() {\n        // @vitest/browser/matchers is NOT exported by vite-plus — should not be rewritten\n        let content = r#\"/// <reference types=\"@vitest/browser/matchers\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_scoped_browser_context() {\n        let content = r#\"/// <reference types=\"@vitest/browser/context\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus/test/browser/context\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_scoped_browser_playwright() {\n        let content = r#\"/// <reference types=\"@vitest/browser/providers/playwright\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/test/browser/providers/playwright\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_scoped_browser_playwright_pkg() {\n        let content = r#\"/// <reference types=\"@vitest/browser-playwright\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/test/browser-playwright\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_scoped_browser_webdriverio() {\n        let content = r#\"/// <reference types=\"@vitest/browser/providers/webdriverio\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/test/browser/providers/webdriverio\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_tsdown_subpath_not_rewritten() {\n        // tsdown subpaths should NOT be rewritten because vite-plus only exports ./pack (no subpaths)\n        let content = r#\"/// <reference types=\"tsdown/client\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_scoped_not_matching() {\n        // Non-enumerated @vitest/* packages should NOT be rewritten\n        let content = r#\"/// <reference types=\"@vitest/coverage-v8\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_inside_template_literal_not_rewritten() {\n        // Reference-like content inside template literals should NOT be rewritten.\n        // The preamble ends at the first non-comment line (`const`), so nothing is processed.\n        let content = r#\"const template = `\n/// <reference types=\"vite/client\" />\n`;\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_preamble_only() {\n        // Only references in the preamble (before first statement) should be rewritten\n        let content = r#\"/// <reference types=\"vite/client\" />\n// A regular comment\n\nconst x = 1;\n/// <reference types=\"vitest\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        // First reference (preamble) is rewritten; last one (after code) is not\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/client\" />\n// A regular comment\n\nconst x = 1;\n/// <reference types=\"vitest\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_after_block_comment() {\n        // Block comments (/* ... */) should not end the preamble scan\n        let content = \"/* License: MIT */\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/* License: MIT */\\n/// <reference types=\\\"vite-plus/client\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_after_multiline_block_comment() {\n        // Multi-line block comments should be skipped entirely\n        let content =\n            \"/*\\n * License header\\n * Copyright 2024\\n */\\n/// <reference types=\\\"vitest\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/*\\n * License header\\n * Copyright 2024\\n */\\n/// <reference types=\\\"vite-plus/test\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_block_comment_with_trailing_code() {\n        // A single-line block comment followed by code should end the preamble\n        let content = \"/* banner */ const x = 1;\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_block_comment_with_trailing_comment() {\n        // A block comment followed by a line comment is still preamble\n        let content = \"/* banner */ // generated\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/* banner */ // generated\\n/// <reference types=\\\"vite-plus/client\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_multiline_block_comment_closes_into_code() {\n        // Multi-line block comment closing line has code after */ — end of preamble\n        let content = \"/*\\n * License\\n */ const x = 1;\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_multiline_block_comment_closes_into_comment() {\n        // Multi-line block comment closing line has only a comment after */ — still preamble\n        let content = \"/*\\n * License\\n */ // end\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/*\\n * License\\n */ // end\\n/// <reference types=\\\"vite-plus/client\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_block_close_into_new_block_comment() {\n        // `/* a */ /* b` closes first comment then opens a new multi-line one\n        let content = \"/* a */ /* b\\n * still going */\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/* a */ /* b\\n * still going */\\n/// <reference types=\\\"vite-plus/client\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_multiple_inline_block_comments_then_code() {\n        // `/* a */ /* b */ const x = 1;` — code after two closed block comments ends preamble\n        let content = \"/* a */ /* b */ const x = 1;\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_multiple_inline_block_comments_no_code() {\n        // `/* a */ /* b */` — only block comments, no trailing code, preamble continues\n        let content = \"/* a */ /* b */\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/* a */ /* b */\\n/// <reference types=\\\"vite-plus/client\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_vitest_browser_providers_playwright() {\n        // @vitest/browser/providers/playwright → vite-plus/test/browser/providers/playwright\n        let content = r#\"/// <reference types=\"@vitest/browser/providers/playwright\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/test/browser/providers/playwright\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_crlf() {\n        // CRLF line endings should be preserved\n        let content =\n            \"/// <reference types=\\\"vite/client\\\" />\\r\\n/// <reference types=\\\"vitest\\\" />\\r\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/// <reference types=\\\"vite-plus/client\\\" />\\r\\n/// <reference types=\\\"vite-plus/test\\\" />\\r\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_crlf_with_block_comment() {\n        // CRLF + block comment header\n        let content =\n            \"/* License */\\r\\n/// <reference types=\\\"vite/client\\\" />\\r\\nconst x = 1;\\r\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"/* License */\\r\\n/// <reference types=\\\"vite-plus/client\\\" />\\r\\nconst x = 1;\\r\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_no_space_after_slashes() {\n        // TypeScript accepts ///<reference without a space\n        let content = r#\"///<reference types=\"vite/client\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"///<reference types=\"vite-plus/client\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_tab_after_slashes() {\n        // TypeScript accepts ///\\t<reference with a tab\n        let content = \"///\\t<reference types=\\\"vite/client\\\" />\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, \"///\\t<reference types=\\\"vite-plus/client\\\" />\");\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_after_shebang() {\n        // Shebang lines should not end the preamble scan\n        let content = \"#!/usr/bin/env node\\n/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"#!/usr/bin/env node\\n/// <reference types=\\\"vite-plus/client\\\" />\\n\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_after_bom() {\n        // UTF-8 BOM should not end the preamble scan; BOM is stripped during rewrite\n        let content = \"\\u{feff}/// <reference types=\\\"vite/client\\\" />\\n\";\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, \"/// <reference types=\\\"vite-plus/client\\\" />\\n\");\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_bare_tsdown() {\n        let content = r#\"/// <reference types=\"tsdown\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"/// <reference types=\"vite-plus/pack\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_already_migrated() {\n        let content = r#\"/// <reference types=\"vite-plus/client\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_preserves_non_matching() {\n        let content = r#\"/// <reference types=\"node\" />\n/// <reference lib=\"es2015\" />\n/// <reference path=\"./types.d.ts\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_with_leading_whitespace() {\n        let content = r#\"  /// <reference types=\"vite/client\" />\"#;\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(result.content, r#\"  /// <reference types=\"vite-plus/client\" />\"#);\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_env_d_ts_style() {\n        let content = r#\"/// <reference types=\"vite/client\" />\n/// <reference types=\"vitest\" />\n/// <reference types=\"vitest/globals\" />\n/// <reference types=\"vitest/config\" />\n/// <reference types=\"@vitest/browser/context\" />\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/client\" />\n/// <reference types=\"vite-plus/test\" />\n/// <reference types=\"vite-plus/test/globals\" />\n/// <reference types=\"vite-plus\" />\n/// <reference types=\"vite-plus/test/browser/context\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_mixed_with_imports() {\n        let content = r#\"/// <reference types=\"vite/client\" />\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({});\"#;\n\n        let result = rewrite_import_content(content, &SkipPackages::default()).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/client\" />\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({});\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_skip_vite() {\n        let content = r#\"/// <reference types=\"vite/client\" />\n/// <reference types=\"vitest\" />\"#;\n\n        let skip_packages =\n            SkipPackages { skip_vite: true, skip_vitest: false, skip_tsdown: false };\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plus/test\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_skip_vitest() {\n        let content = r#\"/// <reference types=\"vite/client\" />\n/// <reference types=\"vitest\" />\n/// <reference types=\"@vitest/browser/matchers\" />\"#;\n\n        let skip_packages =\n            SkipPackages { skip_vite: false, skip_vitest: true, skip_tsdown: false };\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"vite-plus/client\" />\n/// <reference types=\"vitest\" />\n/// <reference types=\"@vitest/browser/matchers\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_skip_tsdown() {\n        let content = r#\"/// <reference types=\"tsdown/client\" />\n/// <reference types=\"vite/client\" />\"#;\n\n        let skip_packages =\n            SkipPackages { skip_vite: false, skip_vitest: false, skip_tsdown: true };\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"/// <reference types=\"tsdown/client\" />\n/// <reference types=\"vite-plus/client\" />\"#\n        );\n    }\n\n    #[test]\n    fn test_rewrite_reference_types_skip_all() {\n        let content = r#\"/// <reference types=\"vite/client\" />\n/// <reference types=\"vitest\" />\n/// <reference types=\"tsdown/client\" />\"#;\n\n        let skip_packages = SkipPackages { skip_vite: true, skip_vitest: true, skip_tsdown: true };\n        let result = rewrite_import_content(content, &skip_packages).unwrap();\n        assert!(!result.updated);\n        assert_eq!(result.content, content);\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/src/lib.rs",
    "content": "mod ast_grep;\nmod eslint;\nmod file_walker;\nmod import_rewriter;\nmod package;\nmod prettier;\nmod script_rewrite;\nmod vite_config;\n\npub use file_walker::{WalkResult, find_ts_files};\npub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory};\npub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts};\npub use vite_config::{MergeResult, merge_json_config, merge_tsdown_config};\n"
  },
  {
    "path": "crates/vite_migration/src/package.rs",
    "content": "use ast_grep_config::RuleConfig;\nuse ast_grep_language::SupportLang;\nuse serde_json::{Map, Value};\nuse vite_error::Error;\n\nuse crate::{ast_grep, eslint::rewrite_eslint_script, prettier::rewrite_prettier_script};\n\n// Marker to replace \"cross-env \" before ast-grep processing\n// Using a fake env var assignment that won't match our rules\nconst CROSS_ENV_MARKER: &str = \"__CROSS_ENV__=1 \";\nconst CROSS_ENV_REPLACEMENT: &str = \"cross-env \";\n\n/// rewrite a single script command string using rules\nfn rewrite_script(script: &str, rules: &[RuleConfig<SupportLang>]) -> String {\n    // Only handle cross-env replacement if it's present in the script\n    let has_cross_env = script.contains(CROSS_ENV_REPLACEMENT);\n\n    // Step 1: Replace \"cross-env \" with marker so ast-grep can see the actual commands\n    let preprocessed = if has_cross_env {\n        script.replace(CROSS_ENV_REPLACEMENT, CROSS_ENV_MARKER)\n    } else {\n        script.to_string()\n    };\n\n    // Step 2: Process with ast-grep\n    let result = ast_grep::apply_loaded_rules(&preprocessed, rules);\n\n    // Step 3: Replace cross-env marker back with \"cross-env \" (only if we replaced it)\n    let result = if has_cross_env {\n        result.replace(CROSS_ENV_MARKER, CROSS_ENV_REPLACEMENT)\n    } else {\n        result\n    };\n\n    result\n}\n\n/// Transform all script strings in a JSON object using the provided function.\n/// Handles both string values and arrays of strings (lint-staged format).\n/// Returns the updated JSON if any scripts were modified, or None if unchanged.\nfn transform_scripts_json(\n    scripts_json: &str,\n    mut transform_fn: impl FnMut(&str) -> String,\n) -> Result<Option<String>, Error> {\n    let mut scripts: Map<String, Value> = serde_json::from_str(scripts_json)?;\n    let mut updated = false;\n\n    for value in scripts.values_mut() {\n        if value.is_array() {\n            // lint-staged scripts can be an array of strings\n            // https://github.com/lint-staged/lint-staged?tab=readme-ov-file#packagejson-example\n            if let Some(sub_scripts) = value.as_array_mut() {\n                for sub_script in sub_scripts.iter_mut() {\n                    if sub_script.is_string()\n                        && let Some(raw_script) = sub_script.as_str()\n                    {\n                        let new_script = transform_fn(raw_script);\n                        if new_script != raw_script {\n                            updated = true;\n                            *sub_script = Value::String(new_script);\n                        }\n                    }\n                }\n            }\n        } else if value.is_string() {\n            if let Some(raw_script) = value.as_str() {\n                let new_script = transform_fn(raw_script);\n                if new_script != raw_script {\n                    updated = true;\n                    *value = Value::String(new_script);\n                }\n            }\n        }\n    }\n\n    if updated {\n        let new_content = serde_json::to_string_pretty(&scripts)?;\n        Ok(Some(new_content))\n    } else {\n        Ok(None)\n    }\n}\n\n/// Rewrite ESLint scripts in JSON content: rename `eslint` → `vp lint` and strip ESLint-only flags.\n///\n/// Uses brush-parser to parse shell commands, so it correctly handles env var prefixes,\n/// compound commands (`&&`, `||`, `|`), and quoted arguments.\npub fn rewrite_eslint(scripts_json: &str) -> Result<Option<String>, Error> {\n    transform_scripts_json(scripts_json, rewrite_eslint_script)\n}\n\n/// Rewrite Prettier scripts in JSON content: rename `prettier` → `vp fmt` and strip Prettier-only flags.\n///\n/// Uses brush-parser to parse shell commands, so it correctly handles env var prefixes,\n/// compound commands (`&&`, `||`, `|`), and quoted arguments.\npub fn rewrite_prettier(scripts_json: &str) -> Result<Option<String>, Error> {\n    transform_scripts_json(scripts_json, rewrite_prettier_script)\n}\n\n/// rewrite scripts json content using rules from rules_yaml\npub fn rewrite_scripts(scripts_json: &str, rules_yaml: &str) -> Result<Option<String>, Error> {\n    let rules = ast_grep::load_rules(rules_yaml)?;\n    transform_scripts_json(scripts_json, |raw_script| rewrite_script(raw_script, &rules))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    const RULES_YAML: &str = r#\"\n# vite --version / vite -v => vp --version / vp -v (global flags, not dev-specific)\n---\nid: replace-vite-version\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vite$'\n  inside:\n    kind: command\n    regex: 'vite\\s+(-v|--version)'\nfix: vp\n\n# vite => vp dev (handles all cases: with/without env var prefix and flag args)\n# Match command_name to preserve env var prefix and arguments\n# Excludes subcommands like \"vite build\", \"vite test\", etc.\n---\nid: replace-vite\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vite$'\n  inside:\n    kind: command\n    not:\n      # ignore non-flag arguments (subcommands like build, test, etc.)\n      regex: 'vite\\s+[^-]'\nfix: vp dev\n\n# vite <subcommand> => vp <subcommand> (handles vite build, vite test, vite dev, etc.)\n# Match command_name when followed by a subcommand, replace only the command name\n---\nid: replace-vite-subcommand\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vite$'\n  inside:\n    kind: command\n    regex: 'vite\\s+[^-]'\nfix: vp\n\n# oxlint => vp lint (handles all cases: with/without env var prefix and args)\n# Match command_name to preserve env var prefix and arguments\n---\nid: replace-oxlint\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^oxlint$'\nfix: vp lint\n\n# oxfmt => vp fmt\n---\nid: replace-oxfmt\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^oxfmt$'\nfix: vp fmt\n\n# vitest => vp test\n---\nid: replace-vitest\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vitest$'\nfix: vp test\n\n# tsdown => vp pack\n---\nid: replace-tsdown\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^tsdown$'\nfix: vp pack\n    \"#;\n\n    #[test]\n    fn test_rewrite_script() {\n        let rules = ast_grep::load_rules(RULES_YAML).unwrap();\n        // vp commands should not be rewritten\n        assert_eq!(rewrite_script(\"vp dev\", &rules), \"vp dev\");\n        assert_eq!(rewrite_script(\"vp build\", &rules), \"vp build\");\n        assert_eq!(rewrite_script(\"vp test\", &rules), \"vp test\");\n        assert_eq!(rewrite_script(\"vp lint\", &rules), \"vp lint\");\n        assert_eq!(rewrite_script(\"vp fmt\", &rules), \"vp fmt\");\n        assert_eq!(rewrite_script(\"vp pack\", &rules), \"vp pack\");\n        assert_eq!(rewrite_script(\"vp dev --port 3000\", &rules), \"vp dev --port 3000\");\n        // vite version flags (global, not dev-specific)\n        assert_eq!(rewrite_script(\"vite --version\", &rules), \"vp --version\");\n        assert_eq!(rewrite_script(\"vite -v\", &rules), \"vp -v\");\n        // vite commands\n        assert_eq!(rewrite_script(\"vite\", &rules), \"vp dev\");\n        assert_eq!(rewrite_script(\"vite dev\", &rules), \"vp dev\");\n        assert_eq!(rewrite_script(\"vite i\", &rules), \"vp i\");\n        assert_eq!(rewrite_script(\"vite install\", &rules), \"vp install\");\n        assert_eq!(rewrite_script(\"vite test\", &rules), \"vp test\");\n        assert_eq!(rewrite_script(\"vite lint\", &rules), \"vp lint\");\n        assert_eq!(rewrite_script(\"vite fmt\", &rules), \"vp fmt\");\n        assert_eq!(rewrite_script(\"vite pack\", &rules), \"vp pack\");\n        assert_eq!(rewrite_script(\"vite preview\", &rules), \"vp preview\");\n        assert_eq!(rewrite_script(\"vite optimize\", &rules), \"vp optimize\");\n        assert_eq!(rewrite_script(\"vite build -r\", &rules), \"vp build -r\");\n        assert_eq!(rewrite_script(\"vite --port 3000\", &rules), \"vp dev --port 3000\");\n        assert_eq!(\n            rewrite_script(\"vite --port 3000 --host 0.0.0.0 --open\", &rules),\n            \"vp dev --port 3000 --host 0.0.0.0 --open\"\n        );\n        assert_eq!(\n            rewrite_script(\"vite --port 3000 || vite --port 3001\", &rules),\n            \"vp dev --port 3000 || vp dev --port 3001\"\n        );\n        assert_eq!(\n            rewrite_script(\"npm run lint && vite --port 3000\", &rules),\n            \"npm run lint && vp dev --port 3000\"\n        );\n        assert_eq!(\n            rewrite_script(\"vite --port 3000 && npm run lint\", &rules),\n            \"vp dev --port 3000 && npm run lint\"\n        );\n        assert_eq!(\n            rewrite_script(\"vite && tsc --check && vite run -r build\", &rules),\n            \"vp dev && tsc --check && vp run -r build\"\n        );\n        assert_eq!(\n            rewrite_script(\"vite && tsc --check && vite run test\", &rules),\n            \"vp dev && tsc --check && vp run test\"\n        );\n        assert_eq!(\n            rewrite_script(\"vite && tsc --check && vite test\", &rules),\n            \"vp dev && tsc --check && vp test\"\n        );\n        assert_eq!(\n            rewrite_script(\"prettier --write src/** vite\", &rules),\n            \"prettier --write src/** vite\"\n        );\n        // complex examples\n        assert_eq!(\n            rewrite_script(\"if [ -f file.txt ]; then vite; fi\", &rules),\n            \"if [ -f file.txt ]; then vp dev; fi\"\n        );\n        assert_eq!(\n            rewrite_script(\"if [ -f file.txt ]; then vite --port 3000; fi\", &rules),\n            \"if [ -f file.txt ]; then vp dev --port 3000; fi\"\n        );\n        assert_eq!(\n            rewrite_script(\"if [ -f file.txt ]; then vite --port 3000 && npm run lint; fi\", &rules,),\n            \"if [ -f file.txt ]; then vp dev --port 3000 && npm run lint; fi\"\n        );\n        assert_eq!(\n            rewrite_script(\n                \"if [ -f file.txt ]; then vite dev --port 3000 && npm run lint; fi\",\n                &rules,\n            ),\n            \"if [ -f file.txt ]; then vp dev --port 3000 && npm run lint; fi\"\n        );\n        // env variable commands\n        assert_eq!(\n            rewrite_script(\"NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite\", &rules),\n            \"NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vp dev\"\n        );\n        assert_eq!(\n            rewrite_script(\"FOO=bar vite --port 3000\", &rules),\n            \"FOO=bar vp dev --port 3000\"\n        );\n        // env variable with oxlint commands\n        assert_eq!(rewrite_script(\"DEBUG=1 oxlint\", &rules), \"DEBUG=1 vp lint\");\n        assert_eq!(\n            rewrite_script(\"NODE_ENV=test oxlint --type-aware\", &rules),\n            \"NODE_ENV=test vp lint --type-aware\"\n        );\n        // oxlint commands\n        assert_eq!(rewrite_script(\"oxlint\", &rules), \"vp lint\");\n        assert_eq!(rewrite_script(\"oxlint --type-aware\", &rules), \"vp lint --type-aware\");\n        assert_eq!(\n            rewrite_script(\"oxlint --type-aware --config .oxlintrc\", &rules),\n            \"vp lint --type-aware --config .oxlintrc\"\n        );\n        assert_eq!(rewrite_script(\"oxlint && vite dev\", &rules), \"vp lint && vp dev\");\n        assert_eq!(\n            rewrite_script(\"npm run type-check && oxlint --type-aware\", &rules),\n            \"npm run type-check && vp lint --type-aware\"\n        );\n        // npx/pnpx/bunx eslint wrappers remain unchanged (no preprocessing)\n        assert_eq!(rewrite_script(\"npx eslint .\", &rules), \"npx eslint .\");\n        assert_eq!(rewrite_script(\"npx eslint --fix .\", &rules), \"npx eslint --fix .\");\n        assert_eq!(rewrite_script(\"pnpx eslint .\", &rules), \"pnpx eslint .\");\n        assert_eq!(rewrite_script(\"bunx eslint .\", &rules), \"bunx eslint .\");\n        assert_eq!(rewrite_script(\"pnpm exec eslint --fix .\", &rules), \"pnpm exec eslint --fix .\");\n        assert_eq!(rewrite_script(\"yarn exec eslint --fix .\", &rules), \"yarn exec eslint --fix .\");\n        // npx with non-eslint tools should NOT be affected\n        assert_eq!(rewrite_script(\"npx prettier .\", &rules), \"npx prettier .\");\n        // npx eslint-plugin-foo should NOT match\n        assert_eq!(rewrite_script(\"npx eslint-plugin-foo\", &rules), \"npx eslint-plugin-foo\");\n        // husky commands should NOT be rewritten by vite-tools rules\n        // (husky rule is in separate vite-prepare.yml, applied only to scripts.prepare)\n        assert_eq!(rewrite_script(\"husky\", &rules), \"husky\");\n        assert_eq!(rewrite_script(\"husky install\", &rules), \"husky install\");\n        assert_eq!(rewrite_script(\"husky || true\", &rules), \"husky || true\");\n    }\n\n    #[test]\n    fn test_rewrite_package_json_scripts_success() {\n        let package_json_scripts = r#\"\n{\n    \"dev\": \"vite\"\n}\n        \"#;\n        let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)\n            .expect(\"failed to rewrite package.json scripts\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"dev\": \"vp dev\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_package_json_scripts_with_env_variable_success() {\n        let package_json_scripts = r#\"\n{\n  \"dev:cjs\": \"VITE_CJS_IGNORE_WARNING=true vite\",\n  \"lint\": \"VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix\"\n}\n        \"#;\n        let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)\n            .expect(\"failed to rewrite package.json scripts\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"dev:cjs\": \"VITE_CJS_IGNORE_WARNING=true vp dev\",\n  \"lint\": \"VITE_CJS_IGNORE_WARNING=true FOO=bar vp lint --fix\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_package_json_scripts_using_cross_env() {\n        let package_json_scripts = r#\"\n{\n  \"dev:cjs\": \"cross-env VITE_CJS_IGNORE_WARNING=true vite && cross-env FOO=bar vitest run\",\n  \"lint\": \"cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix\",\n  \"test\": \"vite build && cross-env FOO=bar vitest run && echo ' cross-env test done ' || echo ' cross-env test failed '\"\n}\n        \"#;\n        let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)\n            .expect(\"failed to rewrite package.json scripts\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"dev:cjs\": \"cross-env VITE_CJS_IGNORE_WARNING=true vp dev && cross-env FOO=bar vp test run\",\n  \"lint\": \"cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar vp lint --fix\",\n  \"test\": \"vp build && cross-env FOO=bar vp test run && echo ' cross-env test done ' || echo ' cross-env test failed '\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_package_json_scripts_lint_staged() {\n        let package_json_scripts = r#\"\n        {\n            \"*.js\": [\"oxlint --fix --type-aware\", \"oxfmt --fix\"],\n            \"*.ts\": \"oxfmt --fix\"\n        }\n        \"#;\n        let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)\n            .expect(\"failed to rewrite package.json scripts\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"*.js\": [\n    \"vp lint --fix --type-aware\",\n    \"vp fmt --fix\"\n  ],\n  \"*.ts\": \"vp fmt --fix\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_package_json_scripts_no_update() {\n        let package_json_scripts = r#\"\n        {\n            \"foo\": \"bar\"\n        }\n        \"#;\n        let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)\n            .expect(\"failed to rewrite package.json scripts\");\n        assert!(updated.is_none());\n    }\n\n    #[test]\n    fn test_rewrite_eslint_json() {\n        let scripts_json = r#\"\n{\n  \"lint\": \"eslint --fix --ext .ts,.tsx .\",\n  \"lint:cached\": \"eslint --cache --fix .\",\n  \"build\": \"vite build\"\n}\n        \"#;\n        let updated = rewrite_eslint(scripts_json).expect(\"failed to rewrite eslint\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"lint\": \"vp lint --fix .\",\n  \"lint:cached\": \"vp lint --fix .\",\n  \"build\": \"vite build\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_eslint_json_no_update() {\n        let scripts_json = r#\"\n{\n  \"lint\": \"vp lint --fix .\",\n  \"build\": \"vite build\"\n}\n        \"#;\n        let updated = rewrite_eslint(scripts_json).expect(\"failed to rewrite eslint\");\n        assert!(updated.is_none());\n    }\n\n    #[test]\n    fn test_rewrite_eslint_json_lint_staged_array() {\n        let scripts_json = r#\"\n{\n  \"*.js\": [\"eslint --ext .ts --fix\", \"oxfmt --fix\"],\n  \"*.ts\": \"eslint --cache --fix\"\n}\n        \"#;\n        let updated = rewrite_eslint(scripts_json).expect(\"failed to rewrite eslint\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"*.js\": [\n    \"vp lint --fix\",\n    \"oxfmt --fix\"\n  ],\n  \"*.ts\": \"vp lint --fix\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_prettier_json() {\n        let scripts_json = r#\"\n{\n  \"format\": \"prettier --write .\",\n  \"format:check\": \"prettier --check .\",\n  \"build\": \"vite build\"\n}\n        \"#;\n        let updated = rewrite_prettier(scripts_json).expect(\"failed to rewrite prettier\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"format\": \"vp fmt .\",\n  \"format:check\": \"vp fmt --check .\",\n  \"build\": \"vite build\"\n}\n        \"#\n            .trim()\n        );\n    }\n\n    #[test]\n    fn test_rewrite_prettier_json_no_update() {\n        let scripts_json = r#\"\n{\n  \"format\": \"vp fmt .\",\n  \"build\": \"vite build\"\n}\n        \"#;\n        let updated = rewrite_prettier(scripts_json).expect(\"failed to rewrite prettier\");\n        assert!(updated.is_none());\n    }\n\n    #[test]\n    fn test_rewrite_prettier_json_lint_staged_array() {\n        let scripts_json = r#\"\n{\n  \"*.js\": [\"prettier --write\", \"eslint --fix\"],\n  \"*.ts\": \"prettier --write --single-quote\"\n}\n        \"#;\n        let updated = rewrite_prettier(scripts_json).expect(\"failed to rewrite prettier\");\n        assert!(updated.is_some());\n        assert_eq!(\n            updated.unwrap(),\n            r#\"\n{\n  \"*.js\": [\n    \"vp fmt\",\n    \"eslint --fix\"\n  ],\n  \"*.ts\": \"vp fmt\"\n}\n        \"#\n            .trim()\n        );\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/src/prettier.rs",
    "content": "use crate::script_rewrite::{FlagConversion, ScriptRewriteConfig, rewrite_script};\n\nconst PRETTIER_CONFIG: ScriptRewriteConfig = ScriptRewriteConfig {\n    source_command: \"prettier\",\n    target_subcommand: \"fmt\",\n    boolean_flags: &[\n        \"--write\",\n        \"-w\",\n        \"--cache\",\n        \"--no-config\",\n        \"--no-editorconfig\",\n        \"--with-node-modules\",\n        \"--require-pragma\",\n        \"--insert-pragma\",\n        \"--no-bracket-spacing\",\n        \"--single-quote\",\n        \"--no-semi\",\n        \"--jsx-single-quote\",\n        \"--bracket-same-line\",\n        \"--use-tabs\",\n        \"--debug-check\",\n        \"--debug-print-doc\",\n        \"--debug-benchmark\",\n        \"--debug-repeat\",\n        \"--experimental-cli\",\n        \"--ignore-unknown\",\n        \"-u\",\n        \"--no-color\",\n        \"--no-plugin-search\",\n    ],\n    value_flags: &[\n        \"--config\",\n        \"--plugin\",\n        \"--parser\",\n        \"--cache-location\",\n        \"--cache-strategy\",\n        \"--log-level\",\n        \"--stdin-filepath\",\n        \"--cursor-offset\",\n        \"--range-start\",\n        \"--range-end\",\n        \"--config-precedence\",\n        \"--tab-width\",\n        \"--print-width\",\n        \"--trailing-comma\",\n        \"--arrow-parens\",\n        \"--prose-wrap\",\n        \"--end-of-line\",\n        \"--html-whitespace-sensitivity\",\n        \"--quote-props\",\n        \"--embedded-language-formatting\",\n        \"--experimental-ternaries\",\n    ],\n    flag_conversions: &[FlagConversion {\n        source_flags: &[\"--list-different\", \"-l\", \"-c\"],\n        target_flag: \"--check\",\n        dedup_flag: \"--check\",\n    }],\n};\n\n/// Rewrite a single script: rename `prettier` → `vp fmt`, strip Prettier-only flags,\n/// and convert `--list-different`/`-l` → `--check`.\npub(crate) fn rewrite_prettier_script(script: &str) -> String {\n    rewrite_script(script, &PRETTIER_CONFIG)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_rewrite_prettier_script() {\n        // Basic rename: prettier → vp fmt\n        assert_eq!(rewrite_prettier_script(\"prettier .\"), \"vp fmt .\");\n        assert_eq!(rewrite_prettier_script(\"prettier --write .\"), \"vp fmt .\");\n        assert_eq!(rewrite_prettier_script(\"prettier --check .\"), \"vp fmt --check .\");\n        assert_eq!(rewrite_prettier_script(\"prettier --list-different .\"), \"vp fmt --check .\");\n        assert_eq!(rewrite_prettier_script(\"prettier -l .\"), \"vp fmt --check .\");\n\n        // Styling flags stripped\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --write --single-quote --tab-width 4 .\"),\n            \"vp fmt .\"\n        );\n        assert_eq!(rewrite_prettier_script(\"prettier --cache --write .\"), \"vp fmt .\");\n        assert_eq!(rewrite_prettier_script(\"prettier --config .prettierrc --write .\"), \"vp fmt .\");\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --plugin prettier-plugin-tailwindcss --write .\"),\n            \"vp fmt .\"\n        );\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --ignore-path .gitignore --write .\"),\n            \"vp fmt --ignore-path .gitignore .\"\n        );\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --ignore-path=.gitignore --write .\"),\n            \"vp fmt --ignore-path=.gitignore .\"\n        );\n\n        // --experimental-cli stripped\n        assert_eq!(rewrite_prettier_script(\"prettier --experimental-cli --write .\"), \"vp fmt .\");\n\n        // cross-env wrapper\n        assert_eq!(\n            rewrite_prettier_script(\"cross-env NODE_ENV=test prettier --write .\"),\n            \"cross-env NODE_ENV=test vp fmt .\"\n        );\n\n        // compound: only prettier segments rewritten, other commands untouched\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --write . && eslint --fix .\"),\n            \"vp fmt . && eslint --fix .\"\n        );\n\n        // pipe: only prettier segment rewritten\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --write . | tee report.txt\"),\n            \"vp fmt . | tee report.txt\"\n        );\n\n        // env var prefix\n        assert_eq!(\n            rewrite_prettier_script(\"NODE_ENV=test prettier --write .\"),\n            \"NODE_ENV=test vp fmt .\"\n        );\n\n        // if clause\n        assert_eq!(\n            rewrite_prettier_script(\"if [ -f .prettierrc ]; then prettier --write .; fi\"),\n            \"if [ -f .prettierrc ]; then vp fmt .; fi\"\n        );\n\n        // npx wrappers unchanged\n        assert_eq!(rewrite_prettier_script(\"npx prettier --write .\"), \"npx prettier --write .\");\n\n        // already rewritten (no-op)\n        assert_eq!(rewrite_prettier_script(\"vp fmt .\"), \"vp fmt .\");\n\n        // no-error-on-unmatched-pattern is kept\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --write --no-error-on-unmatched-pattern .\"),\n            \"vp fmt --no-error-on-unmatched-pattern .\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_prettier_compound_commands() {\n        // subshell (brush-parser adds spaces inside parentheses)\n        assert_eq!(rewrite_prettier_script(\"(prettier --write .)\"), \"( vp fmt . )\");\n\n        // brace group\n        assert_eq!(rewrite_prettier_script(\"{ prettier --write .; }\"), \"{ vp fmt .; }\");\n\n        // if clause\n        assert_eq!(\n            rewrite_prettier_script(\"if [ -f .prettierrc ]; then prettier --write .; fi\"),\n            \"if [ -f .prettierrc ]; then vp fmt .; fi\"\n        );\n\n        // while loop\n        assert_eq!(\n            rewrite_prettier_script(\"while true; do prettier --write .; done\"),\n            \"while true; do vp fmt .; done\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_prettier_cross_env() {\n        // cross-env with prettier\n        assert_eq!(\n            rewrite_prettier_script(\"cross-env NODE_ENV=test prettier --write --cache .\"),\n            \"cross-env NODE_ENV=test vp fmt .\"\n        );\n\n        // cross-env with prettier and --check\n        assert_eq!(\n            rewrite_prettier_script(\"cross-env NODE_ENV=test prettier --check .\"),\n            \"cross-env NODE_ENV=test vp fmt --check .\"\n        );\n\n        // cross-env without prettier — passes through unchanged\n        assert_eq!(\n            rewrite_prettier_script(\"cross-env NODE_ENV=test jest\"),\n            \"cross-env NODE_ENV=test jest\"\n        );\n\n        // multiple env vars before prettier\n        assert_eq!(\n            rewrite_prettier_script(\"cross-env NODE_ENV=test CI=true prettier --write --cache .\"),\n            \"cross-env NODE_ENV=test CI=true vp fmt .\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_prettier_list_different_to_check() {\n        // --list-different → --check\n        assert_eq!(rewrite_prettier_script(\"prettier --list-different .\"), \"vp fmt --check .\");\n        // -l → --check\n        assert_eq!(rewrite_prettier_script(\"prettier -l .\"), \"vp fmt --check .\");\n\n        // --list-different with other flags\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --list-different --single-quote .\"),\n            \"vp fmt --check .\"\n        );\n\n        // --check + --list-different → single --check (no duplicate)\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --check --list-different .\"),\n            \"vp fmt --check .\"\n        );\n    }\n\n    #[test]\n    fn test_rewrite_prettier_short_flags() {\n        // -w is short for --write (stripped)\n        assert_eq!(rewrite_prettier_script(\"prettier -w .\"), \"vp fmt .\");\n        // -c is short for --check (converted)\n        assert_eq!(rewrite_prettier_script(\"prettier -c .\"), \"vp fmt --check .\");\n        // Combined with other flags\n        assert_eq!(rewrite_prettier_script(\"prettier -w --single-quote .\"), \"vp fmt .\");\n        assert_eq!(rewrite_prettier_script(\"prettier -c --single-quote .\"), \"vp fmt --check .\");\n    }\n\n    #[test]\n    fn test_rewrite_prettier_ignore_unknown_stripped() {\n        // --ignore-unknown is a prettier-only flag, should be stripped\n        assert_eq!(\n            rewrite_prettier_script(\n                \"prettier . --cache --write --ignore-unknown --experimental-cli\"\n            ),\n            \"vp fmt .\"\n        );\n        // -u is short for --ignore-unknown\n        assert_eq!(rewrite_prettier_script(\"prettier --write -u .\"), \"vp fmt .\");\n        // --ignore-unknown with --check\n        assert_eq!(\n            rewrite_prettier_script(\"prettier --ignore-unknown --check .\"),\n            \"vp fmt --check .\"\n        );\n        // --no-color stripped\n        assert_eq!(rewrite_prettier_script(\"prettier --no-color --write .\"), \"vp fmt .\");\n        // --no-plugin-search stripped\n        assert_eq!(rewrite_prettier_script(\"prettier --no-plugin-search --write .\"), \"vp fmt .\");\n    }\n\n    #[test]\n    fn test_rewrite_prettier_value_flags() {\n        // --flag=value form\n        assert_eq!(rewrite_prettier_script(\"prettier --tab-width=4 --write .\"), \"vp fmt .\");\n        assert_eq!(rewrite_prettier_script(\"prettier --print-width=120 --write .\"), \"vp fmt .\");\n\n        // Multiple value flags\n        assert_eq!(\n            rewrite_prettier_script(\n                \"prettier --config .prettierrc --plugin prettier-plugin-tailwindcss --write .\"\n            ),\n            \"vp fmt .\"\n        );\n\n        // --parser flag\n        assert_eq!(rewrite_prettier_script(\"prettier --parser typescript --write .\"), \"vp fmt .\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/src/script_rewrite.rs",
    "content": "use brush_parser::ast;\n\n/// Configuration for converting one flag (or set of aliases) into a different flag.\n/// Example: Prettier's `--list-different`/`-l` → `--check`.\npub(crate) struct FlagConversion {\n    /// Source flags that should be converted (e.g. `[\"--list-different\", \"-l\"]`).\n    pub(crate) source_flags: &'static [&'static str],\n    /// The target flag to emit instead (e.g. `\"--check\"`).\n    pub(crate) target_flag: &'static str,\n    /// An existing flag that means the same thing — used for dedup\n    /// (e.g. `\"--check\"` so we don't emit two `--check` flags).\n    pub(crate) dedup_flag: &'static str,\n}\n\n/// Tool-specific configuration for script rewriting.\npub(crate) struct ScriptRewriteConfig {\n    /// The source command name to match (e.g. `\"prettier\"`, `\"eslint\"`).\n    pub(crate) source_command: &'static str,\n    /// The `vp` subcommand to emit (e.g. `\"fmt\"`, `\"lint\"`).\n    pub(crate) target_subcommand: &'static str,\n    /// Boolean flags to strip (consumed alone, e.g. `\"--cache\"`).\n    pub(crate) boolean_flags: &'static [&'static str],\n    /// Value flags to strip (consume the next token, e.g. `\"--config\"`).\n    pub(crate) value_flags: &'static [&'static str],\n    /// Flags to convert to a different flag.\n    pub(crate) flag_conversions: &'static [FlagConversion],\n}\n\n// Shell keywords after which a newline is cosmetic (not a statement terminator).\nconst SHELL_CONTINUATION_KEYWORDS: &[&str] = &[\"then\", \"do\", \"else\", \"elif\", \"in\"];\n\n/// Rewrite a shell script: find `source_command`, rename to `vp <subcommand>`,\n/// strip tool-specific flags, and normalize the output.\npub(crate) fn rewrite_script(script: &str, config: &ScriptRewriteConfig) -> String {\n    let mut parser = brush_parser::Parser::new(\n        script.as_bytes(),\n        &brush_parser::ParserOptions::default(),\n        &brush_parser::SourceInfo::default(),\n    );\n    let Ok(mut program) = parser.parse_program() else {\n        return script.to_owned();\n    };\n\n    if !rewrite_in_program(&mut program, config) {\n        return script.to_owned();\n    }\n    let output = normalize_pipe_spacing(&program.to_string());\n    collapse_newlines(&output)\n}\n\nfn rewrite_in_program(program: &mut ast::Program, config: &ScriptRewriteConfig) -> bool {\n    let mut changed = false;\n    for cmd in &mut program.complete_commands {\n        changed |= rewrite_in_compound_list(cmd, config);\n    }\n    changed\n}\n\nfn rewrite_in_compound_list(list: &mut ast::CompoundList, config: &ScriptRewriteConfig) -> bool {\n    let mut changed = false;\n    for item in &mut list.0 {\n        changed |= rewrite_in_and_or_list(&mut item.0, config);\n    }\n    changed\n}\n\nfn rewrite_in_and_or_list(list: &mut ast::AndOrList, config: &ScriptRewriteConfig) -> bool {\n    let mut changed = rewrite_in_pipeline(&mut list.first, config);\n    for and_or in &mut list.additional {\n        match and_or {\n            ast::AndOr::And(p) | ast::AndOr::Or(p) => {\n                changed |= rewrite_in_pipeline(p, config);\n            }\n        }\n    }\n    changed\n}\n\nfn rewrite_in_pipeline(pipeline: &mut ast::Pipeline, config: &ScriptRewriteConfig) -> bool {\n    let mut changed = false;\n    for cmd in &mut pipeline.seq {\n        match cmd {\n            ast::Command::Simple(simple) => {\n                changed |= rewrite_in_simple_command(simple, config);\n            }\n            ast::Command::Compound(compound, _redirects) => {\n                changed |= rewrite_in_compound_command(compound, config);\n            }\n            _ => {}\n        }\n    }\n    changed\n}\n\nfn rewrite_in_compound_command(\n    cmd: &mut ast::CompoundCommand,\n    config: &ScriptRewriteConfig,\n) -> bool {\n    match cmd {\n        ast::CompoundCommand::BraceGroup(bg) => rewrite_in_compound_list(&mut bg.list, config),\n        ast::CompoundCommand::Subshell(sub) => rewrite_in_compound_list(&mut sub.list, config),\n        ast::CompoundCommand::IfClause(if_cmd) => {\n            let mut changed = rewrite_in_compound_list(&mut if_cmd.condition, config);\n            changed |= rewrite_in_compound_list(&mut if_cmd.then, config);\n            if let Some(elses) = &mut if_cmd.elses {\n                for else_clause in elses {\n                    if let Some(cond) = &mut else_clause.condition {\n                        changed |= rewrite_in_compound_list(cond, config);\n                    }\n                    changed |= rewrite_in_compound_list(&mut else_clause.body, config);\n                }\n            }\n            changed\n        }\n        ast::CompoundCommand::WhileClause(wc) | ast::CompoundCommand::UntilClause(wc) => {\n            let mut changed = rewrite_in_compound_list(&mut wc.0, config);\n            changed |= rewrite_in_compound_list(&mut wc.1.list, config);\n            changed\n        }\n        ast::CompoundCommand::ForClause(fc) => rewrite_in_compound_list(&mut fc.body.list, config),\n        ast::CompoundCommand::ArithmeticForClause(afc) => {\n            rewrite_in_compound_list(&mut afc.body.list, config)\n        }\n        ast::CompoundCommand::CaseClause(cc) => {\n            let mut changed = false;\n            for case_item in &mut cc.cases {\n                if let Some(cmd_list) = &mut case_item.cmd {\n                    changed |= rewrite_in_compound_list(cmd_list, config);\n                }\n            }\n            changed\n        }\n        ast::CompoundCommand::Arithmetic(_) => false,\n    }\n}\n\nfn make_suffix_word(value: &str) -> ast::CommandPrefixOrSuffixItem {\n    ast::CommandPrefixOrSuffixItem::Word(ast::Word { value: value.to_owned(), loc: None })\n}\n\nfn rewrite_in_simple_command(cmd: &mut ast::SimpleCommand, config: &ScriptRewriteConfig) -> bool {\n    let cmd_name = cmd.word_or_name.as_ref().map(|w| w.value.as_str());\n\n    if cmd_name == Some(config.source_command) {\n        if let Some(word) = &mut cmd.word_or_name {\n            word.value = \"vp\".to_owned();\n        }\n        match &mut cmd.suffix {\n            Some(suffix) => suffix.0.insert(0, make_suffix_word(config.target_subcommand)),\n            None => {\n                cmd.suffix =\n                    Some(ast::CommandSuffix(vec![make_suffix_word(config.target_subcommand)]));\n            }\n        }\n        strip_flags_from_suffix(cmd, 1, config);\n        return true;\n    }\n\n    if cmd_name == Some(\"cross-env\") || cmd_name == Some(\"cross-env-shell\") {\n        return rewrite_in_cross_env(cmd, config);\n    }\n\n    false\n}\n\nfn rewrite_in_cross_env(cmd: &mut ast::SimpleCommand, config: &ScriptRewriteConfig) -> bool {\n    let suffix = match &mut cmd.suffix {\n        Some(s) => s,\n        None => return false,\n    };\n\n    let source_idx = suffix.0.iter().position(|item| {\n        matches!(item, ast::CommandPrefixOrSuffixItem::Word(w) if w.value == config.source_command)\n    });\n    let Some(idx) = source_idx else {\n        return false;\n    };\n\n    if let ast::CommandPrefixOrSuffixItem::Word(w) = &mut suffix.0[idx] {\n        w.value = \"vp\".to_owned();\n    }\n    suffix.0.insert(idx + 1, make_suffix_word(config.target_subcommand));\n\n    strip_flags_from_suffix(cmd, idx + 2, config);\n    true\n}\n\n/// Strip tool-specific flags from the suffix, starting at `start_idx`.\n/// Items before `start_idx` are kept unconditionally.\n/// Also applies flag conversions defined in the config.\nfn strip_flags_from_suffix(\n    cmd: &mut ast::SimpleCommand,\n    start_idx: usize,\n    config: &ScriptRewriteConfig,\n) {\n    let suffix = cmd.suffix.as_mut().expect(\"suffix was just set\");\n    let items = std::mem::take(&mut suffix.0);\n    let mut iter = items.into_iter().enumerate();\n\n    // Keep items before start_idx unconditionally\n    for (i, item) in iter.by_ref() {\n        suffix.0.push(item);\n        if i + 1 >= start_idx {\n            break;\n        }\n    }\n\n    let mut skip_next = false;\n    // One dedup tracker per flag conversion rule (no allocation when empty)\n    let mut conversion_emitted = vec![false; config.flag_conversions.len()];\n\n    for (_, item) in iter {\n        if skip_next {\n            skip_next = false;\n            continue;\n        }\n        if let ast::CommandPrefixOrSuffixItem::Word(ref w) = item {\n            let val = w.value.as_str();\n\n            // Boolean flags: strip just this token\n            if config.boolean_flags.contains(&val) {\n                continue;\n            }\n\n            // Value flags: --flag=value form\n            if let Some(eq_pos) = val.find('=')\n                && config.value_flags.contains(&&val[..eq_pos])\n            {\n                continue;\n            }\n\n            // Value flags: --flag value form (strip flag + next token)\n            if config.value_flags.contains(&val) {\n                skip_next = true;\n                continue;\n            }\n\n            // Flag conversions + dedup tracking in a single pass\n            let mut converted = false;\n            for (ci, conv) in config.flag_conversions.iter().enumerate() {\n                if conv.source_flags.contains(&val) {\n                    if !conversion_emitted[ci] {\n                        suffix.0.push(make_suffix_word(conv.target_flag));\n                        conversion_emitted[ci] = true;\n                    }\n                    converted = true;\n                    break;\n                }\n                if val == conv.dedup_flag {\n                    conversion_emitted[ci] = true;\n                }\n            }\n            if converted {\n                continue;\n            }\n        }\n        suffix.0.push(item);\n    }\n\n    if suffix.0.is_empty() {\n        cmd.suffix = None;\n    }\n}\n\n/// Collapse newlines and surrounding whitespace into single-line form.\n/// brush-parser reformats compound commands with newlines + indentation,\n/// but package.json scripts must remain single-line.\nfn collapse_newlines(s: &str) -> String {\n    if !s.contains('\\n') {\n        return s.to_owned();\n    }\n    let mut result = String::with_capacity(s.len());\n    let mut chars = s.chars().peekable();\n    while let Some(c) = chars.next() {\n        if c == '\\n' {\n            while result.ends_with(' ') || result.ends_with('\\t') {\n                result.pop();\n            }\n            while chars.peek().is_some_and(|&ch| ch == ' ' || ch == '\\t') {\n                chars.next();\n            }\n            if needs_semicolon(&result) {\n                result.push_str(\"; \");\n            } else {\n                result.push(' ');\n            }\n        } else {\n            result.push(c);\n        }\n    }\n    result\n}\n\nfn needs_semicolon(before: &str) -> bool {\n    let trimmed = before.trim_end();\n    if trimmed.is_empty() {\n        return false;\n    }\n    let last_byte = trimmed.as_bytes()[trimmed.len() - 1];\n    if matches!(last_byte, b'{' | b'(' | b';' | b'|' | b'&' | b'!') {\n        return false;\n    }\n    for kw in SHELL_CONTINUATION_KEYWORDS {\n        if trimmed.ends_with(kw) {\n            let prefix_len = trimmed.len() - kw.len();\n            if prefix_len == 0 || !trimmed.as_bytes()[prefix_len - 1].is_ascii_alphanumeric() {\n                return false;\n            }\n        }\n    }\n    true\n}\n\n/// Fix pipe spacing in brush-parser Display output.\n/// brush-parser renders pipes as `cmd1 |cmd2` instead of `cmd1 | cmd2`.\nfn normalize_pipe_spacing(s: &str) -> String {\n    let bytes = s.as_bytes();\n    let mut result = Vec::with_capacity(bytes.len() + 16);\n    for i in 0..bytes.len() {\n        result.push(bytes[i]);\n        if bytes[i] == b'|'\n            && i > 0\n            && bytes[i - 1] == b' '\n            && i + 1 < bytes.len()\n            && bytes[i + 1] != b'|'\n            && bytes[i + 1] != b' '\n        {\n            result.push(b' ');\n        }\n    }\n    String::from_utf8(result).unwrap_or_else(|_| s.to_owned())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_normalize_pipe_spacing() {\n        assert_eq!(normalize_pipe_spacing(\"cmd1 |cmd2\"), \"cmd1 | cmd2\");\n        assert_eq!(normalize_pipe_spacing(\"cmd1 | cmd2\"), \"cmd1 | cmd2\");\n        assert_eq!(normalize_pipe_spacing(\"cmd1 || cmd2\"), \"cmd1 || cmd2\");\n        assert_eq!(normalize_pipe_spacing(\"cmd1 && cmd2\"), \"cmd1 && cmd2\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_migration/src/vite_config.rs",
    "content": "use std::{borrow::Cow, path::Path, sync::LazyLock};\n\nuse ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string};\nuse ast_grep_language::{LanguageExt, SupportLang};\nuse regex::Regex;\nuse vite_error::Error;\n\nuse crate::ast_grep;\n\n/// Result of merging JSON config into vite config\n#[derive(Debug)]\npub struct MergeResult {\n    /// The updated vite config content\n    pub content: String,\n    /// Whether any changes were made\n    pub updated: bool,\n    /// Whether the config uses a function callback\n    pub uses_function_callback: bool,\n}\n\n/// Merge a JSON configuration file into vite.config.ts or vite.config.js\n///\n/// This function reads a JSON configuration file and merges it into the vite\n/// configuration file by adding a section with the specified key to the config.\n///\n/// Note: TypeScript parser is used for both .ts and .js files since TypeScript\n/// syntax is a superset of JavaScript.\n///\n/// # Arguments\n///\n/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file\n/// * `json_config_path` - Path to the JSON config file (e.g., .oxlintrc.json, .oxfmtrc.json)\n/// * `config_key` - The key to use in the vite config (e.g., \"lint\", \"fmt\")\n///\n/// # Returns\n///\n/// Returns a `MergeResult` containing:\n/// - `content`: The updated vite config content\n/// - `updated`: Whether any changes were made\n/// - `uses_function_callback`: Whether the config uses a function callback\n///\n/// # Example\n///\n/// ```ignore\n/// use std::path::Path;\n/// use vite_migration::merge_json_config;\n///\n/// // Merge oxlint config with \"lint\" key\n/// let result = merge_json_config(\n///     Path::new(\"vite.config.ts\"),\n///     Path::new(\".oxlintrc\"),\n///     \"lint\",\n/// )?;\n/// // Merge oxfmt config with \"fmt\" key\n/// let result = merge_json_config(\n///     Path::new(\"vite.config.ts\"),\n///     Path::new(\".oxfmtrc.json\"),\n///     \"fmt\",\n///     \"format\",\n/// )?;\n///\n/// if result.updated {\n///     std::fs::write(\"vite.config.ts\", &result.content)?;\n/// }\n/// ```\npub fn merge_json_config(\n    vite_config_path: &Path,\n    json_config_path: &Path,\n    config_key: &str,\n) -> Result<MergeResult, Error> {\n    // Read the vite config file\n    let vite_config_content = std::fs::read_to_string(vite_config_path)?;\n\n    // Read the JSON/JSONC config file directly\n    // JSON/JSONC content is valid JS (comments are valid in JS too)\n    let js_config = std::fs::read_to_string(json_config_path)?;\n\n    // Merge the config\n    merge_json_config_content(&vite_config_content, &js_config, config_key)\n}\n\n/// Merge JSON configuration into vite config content\n///\n/// This is the internal function that performs the actual merge using ast-grep.\n/// It takes the vite config content and the JSON config as a TypeScript object literal string.\n///\n/// # Arguments\n///\n/// * `vite_config_content` - The content of the vite.config.ts or vite.config.js file\n/// * `ts_config` - The config as a TypeScript object literal string\n/// * `config_key` - The key to use in the vite config (e.g., \"lint\", \"fmt\")\n///\n/// # Returns\n///\n/// Returns a `MergeResult` with the updated content and status flags.\nfn merge_json_config_content(\n    vite_config_content: &str,\n    ts_config: &str,\n    config_key: &str,\n) -> Result<MergeResult, Error> {\n    // Check if the config uses a function callback (for informational purposes)\n    let uses_function_callback = check_function_callback(vite_config_content)?;\n\n    // Strip \"$schema\" property — it's a JSON Schema annotation not valid in OxlintConfig\n    let ts_config = strip_schema_property(ts_config);\n\n    // Generate the ast-grep rules with the actual config\n    let rule_yaml = generate_merge_rule(&ts_config, config_key);\n\n    // Apply the transformation\n    let (content, updated) = ast_grep::apply_rules(vite_config_content, &rule_yaml)?;\n\n    Ok(MergeResult { content, updated, uses_function_callback })\n}\n\n/// Regex to match `\"$schema\": \"...\"` lines (with optional trailing comma).\nstatic RE_SCHEMA: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r#\"(?m)^\\s*\"\\$schema\"\\s*:\\s*\"[^\"]*\"\\s*,?\\s*\\n\"#).unwrap());\n\n/// Strip the `\"$schema\"` property from a JSON/JSONC config string.\n///\n/// JSON config files (`.oxlintrc.json`, `.oxfmtrc.json`) often contain a\n/// `\"$schema\"` annotation that is meaningful only for editor validation.\n/// When the JSON content is embedded into `vite.config.ts`, the `$schema`\n/// property causes a TypeScript type error because it is not part of\n/// `OxlintConfig` / `OxfmtConfig`.\nfn strip_schema_property(config: &str) -> Cow<'_, str> {\n    RE_SCHEMA.replace_all(config, \"\")\n}\n\n/// Check if the vite config uses a function callback pattern\nfn check_function_callback(vite_config_content: &str) -> Result<bool, Error> {\n    // Match both sync and async arrow functions\n    let check_rule = r#\"\n---\nid: check-function-callback\nlanguage: TypeScript\nrule:\n  any:\n    - pattern: defineConfig(($PARAMS) => $BODY)\n    - pattern: defineConfig(async ($PARAMS) => $BODY)\n\"#;\n\n    let globals = GlobalRules::default();\n    let rules: Vec<RuleConfig<SupportLang>> =\n        from_yaml_string::<SupportLang>(check_rule, &globals)?;\n\n    for rule in &rules {\n        if rule.language != SupportLang::TypeScript {\n            continue;\n        }\n\n        let grep = rule.language.ast_grep(vite_config_content);\n        let root = grep.root();\n        let matcher = &rule.matcher;\n\n        if root.find(matcher).is_some() {\n            return Ok(true);\n        }\n    }\n\n    Ok(false)\n}\n\n/// Generate the ast-grep rules YAML for merging JSON config\n///\n/// This generates six rules:\n/// 1. For object literal: `defineConfig({ ... })`\n/// 2. For arrow function with direct return: `defineConfig((env) => ({ ... }))`\n/// 3. For return object literal inside defineConfig callback: `return { ... }`\n/// 4. For return variable inside defineConfig callback: `return configObj` -> `return { ..., ...configObj }`\n/// 5. For plain object export: `export default { ... }`\n/// 6. For satisfies pattern: `export default { ... } satisfies Type`\n///\n/// The config is placed first to avoid trailing comma issues.\nfn generate_merge_rule(ts_config: &str, config_key: &str) -> String {\n    // Indent the config to match the YAML structure\n    let indented_config = indent_multiline(ts_config, 4);\n\n    let template = r#\"---\nid: merge-json-config-object\nlanguage: TypeScript\nrule:\n  pattern: |\n    defineConfig({\n      $$$CONFIG\n    })\nfix: |-\n  defineConfig({\n    __CONFIG_KEY__: __JSON_CONFIG__,\n    $$$CONFIG\n  })\n---\nid: merge-json-config-function\nlanguage: TypeScript\nrule:\n  pattern: |\n    defineConfig(($PARAMS) => ({\n      $$$CONFIG\n    }))\nfix: |-\n  defineConfig(($PARAMS) => ({\n    __CONFIG_KEY__: __JSON_CONFIG__,\n    $$$CONFIG\n  }))\n---\nid: merge-json-config-return\nlanguage: TypeScript\nrule:\n  pattern: |\n    return {\n      $$$CONFIG\n    }\n  inside:\n    stopBy: end\n    pattern: defineConfig($$$ARGS)\nfix: |-\n  return {\n    __CONFIG_KEY__: __JSON_CONFIG__,\n    $$$CONFIG\n  }\n---\nid: merge-json-config-return-var\nlanguage: TypeScript\nrule:\n  pattern: return $VAR\n  has:\n    pattern: $VAR\n    kind: identifier\n  inside:\n    stopBy: end\n    pattern: defineConfig($$$ARGS)\nfix: |-\n  return {\n    __CONFIG_KEY__: __JSON_CONFIG__,\n    ...$VAR,\n  }\n---\nid: merge-json-config-plain-export\nlanguage: TypeScript\nrule:\n  pattern: |\n    export default {\n      $$$CONFIG\n    }\nfix: |-\n  export default {\n    __CONFIG_KEY__: __JSON_CONFIG__,\n    $$$CONFIG\n  }\n---\nid: merge-json-config-satisfies\nlanguage: TypeScript\nrule:\n  pattern: |\n    export default {\n      $$$CONFIG\n    } satisfies $TYPE\nfix: |-\n  export default {\n    __CONFIG_KEY__: __JSON_CONFIG__,\n    $$$CONFIG\n  } satisfies $TYPE\n\"#;\n\n    template.replace(\"__CONFIG_KEY__\", config_key).replace(\"__JSON_CONFIG__\", &indented_config)\n}\n\n/// Indent each line of a multiline string\nfn indent_multiline(s: &str, spaces: usize) -> String {\n    let indent = \" \".repeat(spaces);\n    let lines: Vec<&str> = s.lines().collect();\n\n    if lines.len() <= 1 {\n        return s.to_string();\n    }\n\n    // First line doesn't get indented (it's on the same line as the key)\n    // Subsequent lines get the specified indent\n    lines\n        .iter()\n        .enumerate()\n        .map(|(i, line)| if i == 0 { line.to_string() } else { format!(\"{indent}{line}\") })\n        .collect::<Vec<_>>()\n        .join(\"\\n\")\n}\n\n/// Merge tsdown config into vite.config.ts by importing it\n///\n/// This function adds an import statement for the tsdown config file\n/// and adds `pack: tsdownConfig` to the defineConfig.\n///\n/// # Arguments\n///\n/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file\n/// * `tsdown_config_path` - Path to the tsdown.config.ts file (relative path like \"./tsdown.config.ts\")\n///\n/// # Returns\n///\n/// Returns a `MergeResult` with the updated content\npub fn merge_tsdown_config(\n    vite_config_path: &Path,\n    tsdown_config_path: &str,\n) -> Result<MergeResult, Error> {\n    let vite_config_content = std::fs::read_to_string(vite_config_path)?;\n    merge_tsdown_config_content(&vite_config_content, tsdown_config_path)\n}\n\n/// Merge tsdown config into vite config content\n///\n/// This adds:\n/// 1. An import statement: `import tsdownConfig from './tsdown.config.ts'`\n/// 2. The pack config in defineConfig: `pack: tsdownConfig`\n///\n/// This function is idempotent - running it multiple times will not create duplicates.\nfn merge_tsdown_config_content(\n    vite_config_content: &str,\n    tsdown_config_path: &str,\n) -> Result<MergeResult, Error> {\n    let uses_function_callback = check_function_callback(vite_config_content)?;\n\n    // Check if already migrated (idempotency check)\n    if vite_config_content.contains(\"import tsdownConfig from\") {\n        return Ok(MergeResult {\n            content: vite_config_content.to_string(),\n            updated: false,\n            uses_function_callback,\n        });\n    }\n\n    // Step 1: Add import statement at the beginning\n    // Use JavaScript extensions for TypeScript files (TypeScript module resolution convention)\n    // .ts → .js, .mts → .mjs, .cts → .cjs\n    let import_path = if tsdown_config_path.ends_with(\".mts\") {\n        tsdown_config_path.replace(\".mts\", \".mjs\")\n    } else if tsdown_config_path.ends_with(\".cts\") {\n        tsdown_config_path.replace(\".cts\", \".cjs\")\n    } else if tsdown_config_path.ends_with(\".ts\") {\n        tsdown_config_path.replace(\".ts\", \".js\")\n    } else {\n        tsdown_config_path.to_string()\n    };\n    let content_with_import =\n        format!(\"import tsdownConfig from '{import_path}';\\n\\n{vite_config_content}\");\n\n    // Step 2: Add pack: tsdownConfig to defineConfig\n    let pack_rule = generate_merge_rule(\"tsdownConfig\", \"pack\");\n    let (final_content, _) = ast_grep::apply_rules(&content_with_import, &pack_rule)?;\n\n    Ok(MergeResult { content: final_content, updated: true, uses_function_callback })\n}\n\n#[cfg(test)]\nmod tests {\n    use std::io::Write;\n\n    use tempfile::tempdir;\n\n    use super::*;\n\n    #[test]\n    fn test_check_function_callback() {\n        let simple_config = r#\"\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  plugins: [],\n});\n\"#;\n        assert!(!check_function_callback(simple_config).unwrap());\n\n        let function_config = r#\"\nimport { defineConfig } from 'vite';\n\nexport default defineConfig((env) => ({\n  plugins: [],\n  server: {\n    port: env.mode === 'production' ? 8080 : 3000,\n  },\n}));\n\"#;\n        assert!(check_function_callback(function_config).unwrap());\n    }\n\n    #[test]\n    fn test_merge_json_config_content_simple() {\n        let vite_config = r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({});\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({\n  lint: {\n    rules: {\n      'no-console': 'warn',\n    },\n  },\n  \n});\"#\n        );\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n    }\n\n    #[test]\n    fn test_merge_json_config_content_with_existing_config() {\n        let vite_config = r#\"import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n  server: {\n    port: 3000,\n  },\n});\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-unused-vars': 'error',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        assert!(result.updated);\n        assert!(result.content.contains(\"plugins: [react()]\"));\n        assert!(result.content.contains(\"port: 3000\"));\n        assert!(result.content.contains(\"lint:\"));\n        assert!(result.content.contains(\"'no-unused-vars': 'error'\"));\n    }\n\n    #[test]\n    fn test_merge_json_config_content_function_callback() {\n        let vite_config = r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig((env) => ({\n  plugins: [],\n}));\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        assert!(result.uses_function_callback);\n        // Function callbacks are now supported\n        assert!(result.updated);\n        assert!(result.content.contains(\"lint:\"));\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n        // Verify the function callback structure is preserved\n        assert!(result.content.contains(\"(env) =>\"));\n        println!(\"result: {}\", result.content);\n    }\n\n    #[test]\n    fn test_merge_json_config_content_complex_function_callback() {\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n        // Complex function callback with conditional returns\n        // https://vite.dev/config/#conditional-config\n        let vite_config = r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig(({ command, mode, isSsrBuild, isPreview }) => {\n  if (command === 'serve') {\n    return {\n      // dev specific config\n    }\n  } else {\n    // command === 'build'\n    return {\n      // build specific config\n    }\n  }\n});\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        // Detected as function callback\n        assert!(result.uses_function_callback);\n        // Now can be auto-migrated using return statement matching\n        assert!(result.updated);\n        // Both return statements should have lint config added\n        assert_eq!(\n            result.content.matches(\"lint: {\").count(),\n            2,\n            \"Expected 2 lint configs, one for each return statement\"\n        );\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n\n        // https://vite.dev/config/#using-environment-variables-in-config\n        let vite_config = r#\"\nimport { defineConfig, loadEnv } from 'vite'\n\nexport default defineConfig(({ mode }) => {\n  // Load env file based on `mode` in the current working directory.\n  // Set the third parameter to '' to load all env regardless of the\n  // `VITE_` prefix.\n  const env = loadEnv(mode, process.cwd(), '')\n  return {\n    define: {\n      // Provide an explicit app-level constant derived from an env var.\n      __APP_ENV__: JSON.stringify(env.APP_ENV),\n    },\n    // Example: use an env var to set the dev server port conditionally.\n    server: {\n      port: env.APP_PORT ? Number(env.APP_PORT) : 5173,\n    },\n  }\n})\n\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        // Detected as function callback\n        assert!(result.uses_function_callback);\n        // Now can be auto-migrated using return statement matching\n        assert!(result.updated);\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n\n        // https://vite.dev/config/#async-config\n        let vite_config = r#\"\nexport default defineConfig(async ({ command, mode }) => {\n  const data = await asyncFunction()\n  return {\n    // vite config\n  }\n})\n\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        // Detected as function callback\n        assert!(result.uses_function_callback);\n        // Now can be auto-migrated using return statement matching\n        assert!(result.updated);\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n    }\n\n    #[test]\n    fn test_generate_merge_rule() {\n        let config = \"{ rules: { 'no-console': 'warn' } }\";\n\n        // Test with \"lint\" key\n        let rule = generate_merge_rule(config, \"lint\");\n        assert!(rule.contains(\"id: merge-json-config-object\"));\n        assert!(rule.contains(\"id: merge-json-config-function\"));\n        assert!(rule.contains(\"id: merge-json-config-return\"));\n        assert!(rule.contains(\"id: merge-json-config-return-var\"));\n        assert!(rule.contains(\"id: merge-json-config-plain-export\"));\n        assert!(rule.contains(\"id: merge-json-config-satisfies\"));\n        assert!(rule.contains(\"language: TypeScript\"));\n        assert!(rule.contains(\"defineConfig\"));\n        assert!(rule.contains(\"lint:\"));\n        assert!(rule.contains(\"'no-console': 'warn'\"));\n        assert!(rule.contains(\"($PARAMS) =>\"));\n        assert!(rule.contains(\"inside:\"));\n        assert!(rule.contains(\"defineConfig($$$ARGS)\"));\n        assert!(rule.contains(\"export default {\"));\n        assert!(rule.contains(\"...$VAR,\"));\n\n        // Test with \"format\" key\n        let rule = generate_merge_rule(config, \"format\");\n        assert!(rule.contains(\"format:\"));\n        assert!(!rule.contains(\"lint:\"));\n    }\n\n    #[test]\n    fn test_merge_json_config_content_arrow_wrapper() {\n        // Arrow function that wraps defineConfig\n        let vite_config = r#\"import { defineConfig } from \"vite\";\n\nexport default () =>\n  defineConfig({\n    root: \"./\",\n    build: {\n      outDir: \"./build/app\",\n    },\n  });\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n        assert!(result.content.contains(\"lint: {\"));\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n    }\n\n    #[test]\n    fn test_merge_json_config_content_plain_export() {\n        // Plain object export without defineConfig\n        // https://vite.dev/config/#config-intellisense\n        let vite_config = r#\"export default {\n  server: {\n    port: 5173,\n  },\n}\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n        assert!(result.content.contains(\"lint: {\"));\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n        assert!(result.content.contains(\"server: {\"));\n\n        let vite_config = r#\"\nimport type { UserConfig } from 'vite'\n\nexport default {\n  server: {\n    port: 5173,\n  },\n} satisfies UserConfig\n        \"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n        assert!(result.content.contains(\"lint: {\"));\n        assert!(result.content.contains(\"'no-console': 'warn'\"));\n        assert!(result.content.contains(\"server: {\"));\n    }\n\n    #[test]\n    fn test_merge_json_config_content_return_variable() {\n        // Return a variable instead of object literal\n        let vite_config = r#\"import { defineConfig, loadEnv } from 'vite'\n\nexport default defineConfig(({ mode }) => {\n  const env = loadEnv(mode, process.cwd(), '')\n  const configObject = {\n    define: {\n      __APP_ENV__: JSON.stringify(env.APP_ENV),\n    },\n    server: {\n      port: env.APP_PORT ? Number(env.APP_PORT) : 5173,\n    },\n  }\n\n  return configObject\n})\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig, loadEnv } from 'vite'\n\nexport default defineConfig(({ mode }) => {\n  const env = loadEnv(mode, process.cwd(), '')\n  const configObject = {\n    define: {\n      __APP_ENV__: JSON.stringify(env.APP_ENV),\n    },\n    server: {\n      port: env.APP_PORT ? Number(env.APP_PORT) : 5173,\n    },\n  }\n\n  return {\n    lint: {\n      rules: {\n        'no-console': 'warn',\n      },\n    },\n    ...configObject,\n  }\n})\"#\n        );\n        assert!(result.updated);\n        assert!(result.uses_function_callback);\n    }\n\n    #[test]\n    fn test_merge_json_config_content_with_format_key() {\n        // Test merge_json_config_content with \"format\" key (for oxfmt)\n        let vite_config = r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({\n  plugins: [],\n});\"#;\n\n        let format_config = r#\"{\n  indentWidth: 2,\n  lineWidth: 100,\n}\"#;\n\n        let result = merge_json_config_content(vite_config, format_config, \"format\").unwrap();\n        println!(\"result: {}\", result.content);\n        assert!(result.updated);\n        assert!(result.content.contains(\"format: {\"));\n        assert!(result.content.contains(\"indentWidth: 2\"));\n        assert!(result.content.contains(\"lineWidth: 100\"));\n        assert!(!result.content.contains(\"lint:\"));\n    }\n\n    #[test]\n    fn test_merge_json_config_with_files() {\n        // Create temporary directory (automatically cleaned up when dropped)\n        let temp_dir = tempdir().unwrap();\n\n        let vite_config_path = temp_dir.path().join(\"vite.config.ts\");\n        let oxlint_config_path = temp_dir.path().join(\".oxlintrc\");\n\n        // Write test vite config\n        let mut vite_file = std::fs::File::create(&vite_config_path).unwrap();\n        write!(\n            vite_file,\n            r#\"import {{ defineConfig }} from 'vite';\n\nexport default defineConfig({{\n  plugins: [],\n}});\"#\n        )\n        .unwrap();\n\n        // Write test oxlint config\n        let mut oxlint_file = std::fs::File::create(&oxlint_config_path).unwrap();\n        write!(\n            oxlint_file,\n            r#\"{{\n  \"rules\": {{\n    \"no-unused-vars\": \"error\",\n    \"no-console\": \"warn\"\n  }},\n  \"ignorePatterns\": [\"dist\", \"node_modules\"]\n}}\"#\n        )\n        .unwrap();\n\n        // Run the merge\n        let result = merge_json_config(&vite_config_path, &oxlint_config_path, \"lint\").unwrap();\n\n        // Verify the result - JSON content is used directly (double quotes preserved)\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\",\n      \"no-console\": \"warn\"\n    },\n    \"ignorePatterns\": [\"dist\", \"node_modules\"]\n  },\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_merge_json_config_with_jsonc_file() {\n        // Test JSONC support with single-line and block comments\n        let temp_dir = tempdir().unwrap();\n\n        let vite_config_path = temp_dir.path().join(\"vite.config.ts\");\n        let jsonc_config_path = temp_dir.path().join(\".oxfmtrc.jsonc\");\n\n        // Write test vite config\n        let mut vite_file = std::fs::File::create(&vite_config_path).unwrap();\n        write!(\n            vite_file,\n            r#\"import {{ defineConfig }} from 'vite';\n\nexport default defineConfig({{\n  plugins: [],\n}});\"#\n        )\n        .unwrap();\n\n        // Write test JSONC config with comments\n        let mut jsonc_file = std::fs::File::create(&jsonc_config_path).unwrap();\n        write!(\n            jsonc_file,\n            r#\"{{\n  // Formatting options\n  \"indentWidth\": 2,\n  /*\n   * Line width configuration\n   */\n  \"lineWidth\": 100\n}}\"#\n        )\n        .unwrap();\n\n        // Run the merge\n        let result = merge_json_config(&vite_config_path, &jsonc_config_path, \"fmt\").unwrap();\n\n        // Verify the result - JSONC content used directly (comments preserved)\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({\n  fmt: {\n    // Formatting options\n    \"indentWidth\": 2,\n    /*\n     * Line width configuration\n     */\n    \"lineWidth\": 100\n  },\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_merge_json_config_with_inline_comments() {\n        // Test JSONC with inline comments\n        let temp_dir = tempdir().unwrap();\n\n        let vite_config_path = temp_dir.path().join(\"vite.config.ts\");\n        let jsonc_config_path = temp_dir.path().join(\".oxlintrc.jsonc\");\n\n        let mut vite_file = std::fs::File::create(&vite_config_path).unwrap();\n        write!(\n            vite_file,\n            r#\"import {{ defineConfig }} from 'vite';\n\nexport default defineConfig({{\n  plugins: [],\n}});\"#\n        )\n        .unwrap();\n\n        // JSONC with inline comments\n        let mut jsonc_file = std::fs::File::create(&jsonc_config_path).unwrap();\n        write!(\n            jsonc_file,\n            r#\"{{\n  \"rules\": {{\n    \"no-console\": \"warn\" // warn about console.log usage\n  }}\n}}\"#\n        )\n        .unwrap();\n\n        let result = merge_json_config(&vite_config_path, &jsonc_config_path, \"lint\").unwrap();\n\n        // Verify the result - JSONC content used directly (comments preserved)\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({\n  lint: {\n    \"rules\": {\n      \"no-console\": \"warn\" // warn about console.log usage\n    }\n  },\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_strip_schema_property() {\n        // With trailing comma\n        let input = r#\"{\n  \"$schema\": \"https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-json-schema/refs/heads/main/schema.json\",\n  \"rules\": {\n    \"no-console\": \"warn\"\n  }\n}\"#;\n        let result = strip_schema_property(input);\n        assert!(!result.contains(\"$schema\"));\n        assert!(result.contains(r#\"\"no-console\": \"warn\"\"#));\n\n        // Without trailing comma\n        let input = r#\"{\n  \"$schema\": \"https://example.com/schema.json\"\n}\"#;\n        let result = strip_schema_property(input);\n        assert!(!result.contains(\"$schema\"));\n\n        // No $schema - unchanged\n        let input = r#\"{\n  \"rules\": {}\n}\"#;\n        assert_eq!(strip_schema_property(input), input);\n    }\n\n    #[test]\n    fn test_merge_json_config_content_strips_schema() {\n        let vite_config = r#\"import { defineConfig } from 'vite';\n\nexport default defineConfig({});\"#;\n\n        let oxlint_config = r#\"{\n  \"$schema\": \"https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-json-schema/refs/heads/main/schema.json\",\n  \"rules\": {\n    \"no-console\": \"warn\"\n  }\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        assert!(result.updated);\n        assert!(!result.content.contains(\"$schema\"));\n        assert!(result.content.contains(r#\"\"no-console\": \"warn\"\"#));\n    }\n\n    #[test]\n    fn test_indent_multiline() {\n        // Single line - no change\n        assert_eq!(indent_multiline(\"single line\", 4), \"single line\");\n\n        // Empty string\n        assert_eq!(indent_multiline(\"\", 4), \"\");\n\n        // Multiple lines\n        let input = \"first\\nsecond\\nthird\";\n        let expected = \"first\\n    second\\n    third\";\n        assert_eq!(indent_multiline(input, 4), expected);\n    }\n\n    #[test]\n    fn test_merge_json_config_content_no_trailing_comma() {\n        // Config WITHOUT trailing comma - lint is placed first to avoid comma issues\n        let vite_config = r#\"import { defineConfig } from 'vite';\nexport default defineConfig({\n  plugins: []\n});\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"import { defineConfig } from 'vite';\nexport default defineConfig({\n  lint: {\n    rules: {\n      'no-console': 'warn',\n    },\n  },\n  plugins: []\n});\"\n        );\n    }\n\n    #[test]\n    fn test_merge_json_config_content_with_trailing_comma() {\n        // Config WITH trailing comma - no issues since lint is placed first\n        let vite_config = r#\"import { defineConfig } from 'vite'\n\nexport default defineConfig({\n  plugins: [],\n})\"#;\n\n        let oxlint_config = r#\"{\n  rules: {\n    'no-console': 'warn',\n  },\n}\"#;\n\n        let result = merge_json_config_content(vite_config, oxlint_config, \"lint\").unwrap();\n        println!(\"result: {}\", result.content);\n        assert!(result.updated);\n        assert_eq!(\n            result.content,\n            \"import { defineConfig } from 'vite'\n\nexport default defineConfig({\n  lint: {\n    rules: {\n      'no-console': 'warn',\n    },\n  },\n  plugins: [],\n})\"\n        );\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_simple() {\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n});\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.ts\").unwrap();\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n        // TypeScript files use .js extension in imports\n        assert_eq!(\n            result.content,\n            r#\"import tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: tsdownConfig,\n  plugins: [],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_with_existing_imports() {\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n});\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.ts\").unwrap();\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n        assert_eq!(\n            result.content,\n            r#\"import tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  pack: tsdownConfig,\n  plugins: [react()],\n});\"#\n        );\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_function_callback() {\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig((env) => ({\n  plugins: [],\n}));\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.ts\").unwrap();\n        assert!(result.updated);\n        assert!(result.uses_function_callback);\n        assert_eq!(\n            result.content,\n            r#\"import tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig((env) => ({\n  pack: tsdownConfig,\n  plugins: [],\n}));\"#\n        );\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_idempotent() {\n        // Already migrated config - import at the beginning\n        let already_migrated = r#\"import tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: tsdownConfig,\n  plugins: [],\n});\"#;\n\n        let result = merge_tsdown_config_content(already_migrated, \"./tsdown.config.ts\").unwrap();\n        assert!(!result.updated, \"Should not update already migrated config\");\n        assert_eq!(result.content, already_migrated);\n\n        // Run migration twice and verify no duplicates\n        let fresh_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n});\"#;\n\n        let expected_migrated = r#\"import tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: tsdownConfig,\n  plugins: [],\n});\"#;\n\n        let first_result = merge_tsdown_config_content(fresh_config, \"./tsdown.config.ts\").unwrap();\n        assert!(first_result.updated);\n        assert_eq!(first_result.content, expected_migrated);\n\n        // Run again on the result - should return unchanged\n        let second_result =\n            merge_tsdown_config_content(&first_result.content, \"./tsdown.config.ts\").unwrap();\n        assert!(!second_result.updated, \"Second migration should not update\");\n        assert_eq!(second_result.content, expected_migrated);\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_no_imports() {\n        // vite.config.ts without any import statements\n        let vite_config = r#\"export default {\n  server: { port: 3000 }\n}\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.ts\").unwrap();\n        assert!(result.updated);\n        assert!(!result.uses_function_callback);\n        assert_eq!(\n            result.content,\n            r#\"import tsdownConfig from './tsdown.config.js';\n\nexport default {\n  pack: tsdownConfig,\n  server: { port: 3000 }\n}\"#\n        );\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_no_false_positive_stdlib() {\n        // \"stdlib:\" should not be detected as \"pack:\" key\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  stdlib: 'some-value',\n});\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.ts\").unwrap();\n        assert!(result.updated);\n        assert!(result.content.contains(\"import tsdownConfig from './tsdown.config.js'\"));\n        assert!(result.content.contains(\"pack: tsdownConfig\"));\n        assert!(result.content.contains(\"stdlib: 'some-value'\"));\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_mts_extension() {\n        // .mts files should use .mjs extension in imports\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({});\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.mts\").unwrap();\n        assert!(result.updated);\n        assert!(result.content.contains(\"import tsdownConfig from './tsdown.config.mjs'\"));\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_cts_extension() {\n        // .cts files should use .cjs extension in imports\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({});\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.cts\").unwrap();\n        assert!(result.updated);\n        assert!(result.content.contains(\"import tsdownConfig from './tsdown.config.cjs'\"));\n    }\n\n    #[test]\n    fn test_merge_tsdown_config_content_js_extension_unchanged() {\n        // .js, .mjs, .cjs files should keep their extensions unchanged\n        let vite_config = r#\"import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({});\"#;\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.js\").unwrap();\n        assert!(result.content.contains(\"import tsdownConfig from './tsdown.config.js'\"));\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.mjs\").unwrap();\n        assert!(result.content.contains(\"import tsdownConfig from './tsdown.config.mjs'\"));\n\n        let result = merge_tsdown_config_content(vite_config, \"./tsdown.config.cjs\").unwrap();\n        assert!(result.content.contains(\"import tsdownConfig from './tsdown.config.cjs'\"));\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/Cargo.toml",
    "content": "[package]\nname = \"vite_shared\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\nrust-version.workspace = true\n\n[dependencies]\ndirectories = { workspace = true }\nnix = { workspace = true, features = [\"poll\", \"term\"] }\nowo-colors = { workspace = true }\nserde = { workspace = true }\nserde_json = { workspace = true }\nsupports-color = \"3\"\ntracing-subscriber = { workspace = true }\nvite_path = { workspace = true }\nvite_str = { workspace = true }\n\n[lints]\nworkspace = true\n"
  },
  {
    "path": "crates/vite_shared/src/env_config.rs",
    "content": "//! Centralized environment variable configuration.\n//!\n//! Reads all known env vars once, provides global access via `EnvConfig::get()`.\n//! Tests use `EnvConfig::test_scope()` for thread-local overrides — no `unsafe`\n//! env mutation, no `#[serial]`, full parallelism.\n//!\n//! # Usage\n//!\n//! ```rust\n//! use vite_shared::EnvConfig;\n//!\n//! // Production: initialize once in main()\n//! // EnvConfig::init();\n//!\n//! // Access anywhere:\n//! let config = EnvConfig::get();\n//! ```\n//!\n//! # Tests\n//!\n//! ```rust\n//! use vite_shared::EnvConfig;\n//!\n//! // Override config for this test (thread-local, parallel-safe)\n//! EnvConfig::test_scope(\n//!     EnvConfig::for_test_with_home(\"/tmp/test\"),\n//!     || {\n//!         assert_eq!(\n//!             EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(),\n//!             \"/tmp/test\"\n//!         );\n//!     },\n//! );\n//! ```\n\nuse std::{cell::RefCell, path::PathBuf, sync::OnceLock};\n\nuse crate::env_vars;\n\n/// Global config initialized once in `main()`.\nstatic ENV_CONFIG: OnceLock<EnvConfig> = OnceLock::new();\n\nthread_local! {\n    /// Thread-local test override. Each test thread gets its own slot.\n    static TEST_CONFIG: RefCell<Option<EnvConfig>> = const { RefCell::new(None) };\n}\n\n/// Centralized configuration read from environment variables.\n///\n/// All known vite-plus environment variables are read once at construction\n/// time. Use `EnvConfig::get()` to access the current config from anywhere.\n#[derive(Debug, Clone)]\npub struct EnvConfig {\n    /// Override for the vite-plus home directory (`~/.vite-plus`).\n    ///\n    /// Env: `VITE_PLUS_HOME`\n    pub vite_plus_home: Option<PathBuf>,\n\n    /// NPM registry URL.\n    ///\n    /// Env: `npm_config_registry` or `NPM_CONFIG_REGISTRY`\n    ///\n    /// Defaults to `https://registry.npmjs.org`.\n    pub npm_registry: String,\n\n    /// Node.js distribution mirror URL.\n    ///\n    /// Env: `VITE_NODE_DIST_MIRROR`\n    pub node_dist_mirror: Option<String>,\n\n    /// Whether running in a CI environment.\n    ///\n    /// Env: `CI`\n    pub is_ci: bool,\n\n    /// Bypass the vite-plus shim and use the system tool directly.\n    ///\n    /// Env: `VITE_PLUS_BYPASS`\n    pub bypass_shim: bool,\n\n    /// Enable debug output for shim dispatch.\n    ///\n    /// Env: `VITE_PLUS_DEBUG_SHIM`\n    pub debug_shim: bool,\n\n    /// Enable eval mode for `vp env use`.\n    ///\n    /// Env: `VITE_PLUS_ENV_USE_EVAL_ENABLE`\n    pub env_use_eval_enable: bool,\n\n    /// Recursion guard for `vp env exec`.\n    ///\n    /// Env: `VITE_PLUS_TOOL_RECURSION`\n    pub tool_recursion: Option<String>,\n\n    /// Override directory for global CLI JS scripts.\n    ///\n    /// Env: `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR`\n    pub js_scripts_dir: Option<String>,\n\n    /// Filter for update task types.\n    ///\n    /// Env: `VITE_UPDATE_TASK_TYPES`\n    pub update_task_types: Option<String>,\n\n    /// Override Node.js version (takes highest priority in version resolution).\n    ///\n    /// Env: `VITE_PLUS_NODE_VERSION`\n    pub node_version: Option<String>,\n\n    /// User home directory.\n    ///\n    /// Env: `HOME` (Unix) / `USERPROFILE` (Windows)\n    pub user_home: Option<PathBuf>,\n\n    /// Fish shell version (indicates running under fish).\n    ///\n    /// Env: `FISH_VERSION`\n    pub fish_version: Option<String>,\n\n    /// PowerShell module path (indicates running under PowerShell on Windows).\n    ///\n    /// Env: `PSModulePath`\n    pub ps_module_path: Option<String>,\n}\n\nimpl EnvConfig {\n    /// Read configuration from the real process environment.\n    ///\n    /// Called once in `main()` via `EnvConfig::init()`.\n    pub fn from_env() -> Self {\n        Self {\n            vite_plus_home: std::env::var(env_vars::VITE_PLUS_HOME).ok().map(PathBuf::from),\n            npm_registry: std::env::var(env_vars::NPM_CONFIG_REGISTRY)\n                .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER))\n                .unwrap_or_else(|_| \"https://registry.npmjs.org\".into())\n                .trim_end_matches('/')\n                .to_string(),\n            node_dist_mirror: std::env::var(env_vars::VITE_NODE_DIST_MIRROR).ok(),\n            is_ci: std::env::var(\"CI\").is_ok(),\n            bypass_shim: std::env::var(env_vars::VITE_PLUS_BYPASS).is_ok(),\n            debug_shim: std::env::var(env_vars::VITE_PLUS_DEBUG_SHIM).is_ok(),\n            env_use_eval_enable: std::env::var(env_vars::VITE_PLUS_ENV_USE_EVAL_ENABLE).is_ok(),\n            tool_recursion: std::env::var(env_vars::VITE_PLUS_TOOL_RECURSION).ok(),\n            js_scripts_dir: std::env::var(env_vars::VITE_GLOBAL_CLI_JS_SCRIPTS_DIR).ok(),\n            update_task_types: std::env::var(env_vars::VITE_UPDATE_TASK_TYPES).ok(),\n            node_version: std::env::var(env_vars::VITE_PLUS_NODE_VERSION).ok(),\n            user_home: std::env::var(\"HOME\")\n                .or_else(|_| std::env::var(\"USERPROFILE\"))\n                .ok()\n                .map(PathBuf::from),\n            fish_version: std::env::var(\"FISH_VERSION\").ok(),\n            ps_module_path: std::env::var(\"PSModulePath\").ok(),\n        }\n    }\n\n    /// Initialize the global config from the process environment.\n    ///\n    /// Call once at program startup (in `main()`).\n    /// Subsequent calls are no-ops.\n    pub fn init() {\n        let _ = ENV_CONFIG.set(Self::from_env());\n    }\n\n    /// Get the current config.\n    ///\n    /// Priority: thread-local test override > global > from_env().\n    ///\n    /// This is the primary way to access configuration throughout the codebase.\n    pub fn get() -> Self {\n        TEST_CONFIG.with(|c| {\n            c.borrow()\n                .clone()\n                .unwrap_or_else(|| ENV_CONFIG.get().cloned().unwrap_or_else(Self::from_env))\n        })\n    }\n\n    /// Run a closure with a test config override (thread-local, parallel-safe).\n    ///\n    /// The override only applies to the current thread.\n    /// Other test threads see their own overrides or the global config.\n    ///\n    /// # Example\n    ///\n    /// ```rust\n    /// use vite_shared::EnvConfig;\n    ///\n    /// EnvConfig::test_scope(\n    ///     EnvConfig::for_test_with_home(\"/tmp/test\"),\n    ///     || {\n    ///         let config = EnvConfig::get();\n    ///         assert_eq!(\n    ///             config.vite_plus_home.as_ref().unwrap().to_str().unwrap(),\n    ///             \"/tmp/test\"\n    ///         );\n    ///     },\n    /// );\n    /// ```\n    pub fn test_scope<R>(config: Self, f: impl FnOnce() -> R) -> R {\n        TEST_CONFIG.with(|c| {\n            let prev = c.borrow_mut().replace(config);\n            let result = f();\n            *c.borrow_mut() = prev;\n            result\n        })\n    }\n\n    /// Create a test configuration with sensible defaults.\n    ///\n    /// No environment variables are read. Use struct update syntax\n    /// to override specific fields:\n    ///\n    /// ```rust\n    /// # use vite_shared::EnvConfig;\n    /// let config = EnvConfig {\n    ///     npm_registry: \"https://custom.registry.example\".into(),\n    ///     ..EnvConfig::for_test()\n    /// };\n    /// ```\n    pub fn for_test() -> Self {\n        Self {\n            vite_plus_home: None,\n            npm_registry: \"https://registry.npmjs.org\".into(),\n            node_dist_mirror: None,\n            is_ci: false,\n            bypass_shim: false,\n            debug_shim: false,\n            env_use_eval_enable: false,\n            tool_recursion: None,\n            js_scripts_dir: None,\n            update_task_types: None,\n            node_version: None,\n            user_home: None,\n            fish_version: None,\n            ps_module_path: None,\n        }\n    }\n\n    /// Create a test configuration with a custom home directory.\n    pub fn for_test_with_home(home: impl Into<PathBuf>) -> Self {\n        Self { vite_plus_home: Some(home.into()), ..Self::for_test() }\n    }\n\n    /// Set a test config override and return a guard that restores the previous on drop.\n    /// Works with async tests since it uses RAII instead of closures.\n    pub fn test_guard(config: Self) -> TestEnvGuard {\n        let prev = TEST_CONFIG.with(|c| c.borrow_mut().replace(config));\n        TestEnvGuard { prev }\n    }\n}\n\n/// RAII guard for test config override. Restores previous config on drop.\npub struct TestEnvGuard {\n    prev: Option<EnvConfig>,\n}\n\nimpl Drop for TestEnvGuard {\n    fn drop(&mut self) {\n        TEST_CONFIG.with(|c| {\n            *c.borrow_mut() = self.prev.take();\n        });\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_for_test_returns_defaults() {\n        let config = EnvConfig::for_test();\n        assert!(config.vite_plus_home.is_none());\n        assert_eq!(config.npm_registry, \"https://registry.npmjs.org\");\n        assert!(!config.is_ci);\n        assert!(!config.bypass_shim);\n    }\n\n    #[test]\n    fn test_for_test_with_home() {\n        let config = EnvConfig::for_test_with_home(\"/tmp/test-home\");\n        assert_eq!(config.vite_plus_home, Some(PathBuf::from(\"/tmp/test-home\")));\n    }\n\n    #[test]\n    fn test_struct_update_syntax() {\n        let config = EnvConfig {\n            npm_registry: \"https://custom.registry\".into(),\n            is_ci: true,\n            ..EnvConfig::for_test()\n        };\n        assert_eq!(config.npm_registry, \"https://custom.registry\");\n        assert!(config.is_ci);\n        assert!(config.vite_plus_home.is_none());\n    }\n\n    #[test]\n    fn test_scope_overrides_get() {\n        EnvConfig::test_scope(EnvConfig::for_test_with_home(\"/scoped/home\"), || {\n            let config = EnvConfig::get();\n            assert_eq!(config.vite_plus_home.as_ref().unwrap().to_str().unwrap(), \"/scoped/home\");\n        });\n    }\n\n    #[test]\n    fn test_scope_restores_previous() {\n        let before = EnvConfig::get();\n        EnvConfig::test_scope(EnvConfig::for_test_with_home(\"/tmp/scope\"), || {\n            assert!(EnvConfig::get().vite_plus_home.is_some());\n        });\n        let after = EnvConfig::get();\n        assert_eq!(before.vite_plus_home.is_some(), after.vite_plus_home.is_some());\n    }\n\n    #[test]\n    fn test_nested_scopes() {\n        EnvConfig::test_scope(EnvConfig::for_test_with_home(\"/outer\"), || {\n            assert_eq!(\n                EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(),\n                \"/outer\"\n            );\n            EnvConfig::test_scope(EnvConfig::for_test_with_home(\"/inner\"), || {\n                assert_eq!(\n                    EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(),\n                    \"/inner\"\n                );\n            });\n            // Restored to outer\n            assert_eq!(\n                EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(),\n                \"/outer\"\n            );\n        });\n    }\n\n    #[test]\n    fn test_from_env_runs_without_panic() {\n        let _config = EnvConfig::from_env();\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/src/env_vars.rs",
    "content": "//! Centralized environment variable name constants.\n//!\n//! Every vite-plus-specific environment variable is defined here as a `&str`\n//! constant. Using these constants instead of string literals ensures:\n//!\n//! - **Single source of truth** — each name defined once.\n//! - **Compile-time typo detection** — a misspelled constant name won't compile.\n//! - **Easy discoverability** — grep this file to see all env vars.\n//!\n//! Standard system variables (`PATH`, `HOME`, `CI`, etc.) are intentionally\n//! excluded — they're well-known and benefit less from constant definitions.\n\n// ── Config: read once at startup via EnvConfig ──────────────────────────\n\n/// Override for the vite-plus home directory (default: `~/.vite-plus`).\npub const VITE_PLUS_HOME: &str = \"VITE_PLUS_HOME\";\n\n/// Log filter string for `tracing_subscriber` (e.g. `\"debug\"`, `\"vite_task=trace\"`).\npub const VITE_LOG: &str = \"VITE_LOG\";\n\n/// NPM registry URL (lowercase form, highest priority).\npub const NPM_CONFIG_REGISTRY: &str = \"npm_config_registry\";\n\n/// NPM registry URL (uppercase fallback).\npub const NPM_CONFIG_REGISTRY_UPPER: &str = \"NPM_CONFIG_REGISTRY\";\n\n/// Node.js distribution mirror URL for downloads.\npub const VITE_NODE_DIST_MIRROR: &str = \"VITE_NODE_DIST_MIRROR\";\n\n/// Override Node.js version (takes highest priority in version resolution).\npub const VITE_PLUS_NODE_VERSION: &str = \"VITE_PLUS_NODE_VERSION\";\n\n/// Enable debug output for shim dispatch.\npub const VITE_PLUS_DEBUG_SHIM: &str = \"VITE_PLUS_DEBUG_SHIM\";\n\n/// Enable eval mode for `vp env use`.\npub const VITE_PLUS_ENV_USE_EVAL_ENABLE: &str = \"VITE_PLUS_ENV_USE_EVAL_ENABLE\";\n\n/// Filter for update task types.\npub const VITE_UPDATE_TASK_TYPES: &str = \"VITE_UPDATE_TASK_TYPES\";\n\n/// Override directory for global CLI JS scripts.\npub const VITE_GLOBAL_CLI_JS_SCRIPTS_DIR: &str = \"VITE_GLOBAL_CLI_JS_SCRIPTS_DIR\";\n\n// ── Runtime: set/removed during shim dispatch for child processes ────────\n\n/// Bypass the vite-plus shim and use the system tool directly.\n///\n/// Value is a `PATH`-style list of directories to bypass.\npub const VITE_PLUS_BYPASS: &str = \"VITE_PLUS_BYPASS\";\n\n/// Recursion guard for `vp env exec` — prevents infinite shim loops.\npub const VITE_PLUS_TOOL_RECURSION: &str = \"VITE_PLUS_TOOL_RECURSION\";\n\n/// Set by shim dispatch to record the active Node.js version.\npub const VITE_PLUS_ACTIVE_NODE: &str = \"VITE_PLUS_ACTIVE_NODE\";\n\n/// Set by shim dispatch to record how the Node.js version was resolved.\npub const VITE_PLUS_RESOLVE_SOURCE: &str = \"VITE_PLUS_RESOLVE_SOURCE\";\n\n/// Set by shell wrapper scripts to indicate which tool is being shimmed.\npub const VITE_PLUS_SHIM_TOOL: &str = \"VITE_PLUS_SHIM_TOOL\";\n\n/// Set by Windows shim wrappers that route through `vp env exec`.\n///\n/// When present, `env exec` can normalize wrapper-inserted argument separators\n/// before forwarding to the actual tool.\npub const VITE_PLUS_SHIM_WRAPPER: &str = \"VITE_PLUS_SHIM_WRAPPER\";\n\n/// Path to the vp binary, passed to JS scripts so they can invoke CLI commands.\npub const VITE_PLUS_CLI_BIN: &str = \"VITE_PLUS_CLI_BIN\";\n\n/// Global CLI version, passed from Rust binary to JS for --version display.\npub const VITE_PLUS_GLOBAL_VERSION: &str = \"VITE_PLUS_GLOBAL_VERSION\";\n\n// ── Testing / Development ───────────────────────────────────────────────\n\n/// Override the trampoline binary path for tests.\n///\n/// When set, `get_trampoline_path()` uses this path instead of resolving\n/// relative to `current_exe()`. Only used in test environments.\npub const VITE_PLUS_TRAMPOLINE_PATH: &str = \"VITE_PLUS_TRAMPOLINE_PATH\";\n"
  },
  {
    "path": "crates/vite_shared/src/header.rs",
    "content": "//! Shared Vite+ header rendering.\n//!\n//! Header coloring behavior:\n//! - Colorization and truecolor capability gates\n//! - Foreground color OSC query (`ESC ] 10 ; ? BEL`) with timeout\n//! - ANSI palette queries for blue/magenta with timeout\n//! - DA1 sandwich technique to detect unsupported terminals\n//! - Stream-based response parsing (modelled after `terminal-colorsaurus`)\n//! - Gradient/fade generation and RGB ANSI coloring\n\nuse std::{\n    io::IsTerminal,\n    sync::{LazyLock, OnceLock},\n};\n#[cfg(unix)]\nuse std::{\n    io::Write,\n    time::{Duration, Instant},\n};\n\nuse supports_color::{Stream, on};\n\n#[cfg(unix)]\nconst ESC: &str = \"\\x1b\";\nconst CSI: &str = \"\\x1b[\";\nconst RESET: &str = \"\\x1b[0m\";\n\nconst HEADER_SUFFIX: &str = \" - The Unified Toolchain for the Web\";\n\nconst RESET_FG: &str = \"\\x1b[39m\";\nconst DEFAULT_BLUE: Rgb = Rgb(88, 146, 255);\nconst DEFAULT_MAGENTA: Rgb = Rgb(187, 116, 247);\nconst ANSI_BLUE_INDEX: u8 = 4;\nconst ANSI_MAGENTA_INDEX: u8 = 5;\nconst HEADER_SUFFIX_FADE_GAMMA: f64 = 1.35;\n\nstatic HEADER_COLORS: OnceLock<HeaderColors> = OnceLock::new();\n\n/// Whether the terminal is Warp, which does not respond to OSC color queries\n/// and renders alternate screen content flush against block edges.\n#[must_use]\npub fn is_warp_terminal() -> bool {\n    static IS_WARP: LazyLock<bool> =\n        LazyLock::new(|| std::env::var(\"TERM_PROGRAM\").as_deref() == Ok(\"WarpTerminal\"));\n    *IS_WARP\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\nstruct Rgb(u8, u8, u8);\n\nstruct HeaderColors {\n    blue: Rgb,\n    suffix_gradient: Vec<Rgb>,\n}\n\nfn bold(text: &str, enabled: bool) -> String {\n    if enabled { format!(\"\\x1b[1m{text}\\x1b[22m\") } else { text.to_string() }\n}\n\nfn fg_rgb(color: Rgb) -> String {\n    format!(\"{CSI}38;2;{};{};{}m\", color.0, color.1, color.2)\n}\n\nfn should_colorize() -> bool {\n    let stdout = std::io::stdout();\n    stdout.is_terminal() && on(Stream::Stdout).is_some()\n}\n\nfn supports_true_color() -> bool {\n    let stdout = std::io::stdout();\n    stdout.is_terminal() && on(Stream::Stdout).is_some_and(|color| color.has_16m)\n}\n\nfn lerp(a: f64, b: f64, t: f64) -> f64 {\n    a + (b - a) * t\n}\n\nfn gradient_eased(count: usize, start: Rgb, end: Rgb, gamma: f64) -> Vec<Rgb> {\n    let n = count.max(1);\n    let denom = (n - 1).max(1) as f64;\n\n    (0..n)\n        .map(|i| {\n            let t = (i as f64 / denom).powf(gamma);\n            Rgb(\n                lerp(start.0 as f64, end.0 as f64, t).round() as u8,\n                lerp(start.1 as f64, end.1 as f64, t).round() as u8,\n                lerp(start.2 as f64, end.2 as f64, t).round() as u8,\n            )\n        })\n        .collect()\n}\n\nfn gradient_three_stop(count: usize, start: Rgb, middle: Rgb, end: Rgb, gamma: f64) -> Vec<Rgb> {\n    let n = count.max(1);\n    let denom = (n - 1).max(1) as f64;\n\n    (0..n)\n        .map(|i| {\n            let t = i as f64 / denom;\n            if t <= 0.5 {\n                let local_t = (t / 0.5).powf(gamma);\n                Rgb(\n                    lerp(start.0 as f64, middle.0 as f64, local_t).round() as u8,\n                    lerp(start.1 as f64, middle.1 as f64, local_t).round() as u8,\n                    lerp(start.2 as f64, middle.2 as f64, local_t).round() as u8,\n                )\n            } else {\n                let local_t = ((t - 0.5) / 0.5).powf(gamma);\n                Rgb(\n                    lerp(middle.0 as f64, end.0 as f64, local_t).round() as u8,\n                    lerp(middle.1 as f64, end.1 as f64, local_t).round() as u8,\n                    lerp(middle.2 as f64, end.2 as f64, local_t).round() as u8,\n                )\n            }\n        })\n        .collect()\n}\n\nfn colorize(text: &str, colors: &[Rgb]) -> String {\n    if text.is_empty() {\n        return String::new();\n    }\n\n    let chars: Vec<char> = text.chars().collect();\n    let denom = (chars.len() - 1).max(1) as f64;\n    let max_idx = colors.len().saturating_sub(1) as f64;\n\n    let mut out = String::new();\n    for (i, ch) in chars.into_iter().enumerate() {\n        let idx = ((i as f64 / denom) * max_idx).round() as usize;\n        out.push_str(&fg_rgb(colors[idx]));\n        out.push(ch);\n    }\n    out.push_str(RESET);\n    out\n}\n\n#[cfg(unix)]\nfn to_8bit(hex: &str) -> Option<u8> {\n    match hex.len() {\n        2 => u8::from_str_radix(hex, 16).ok(),\n        4 => {\n            let value = u16::from_str_radix(hex, 16).ok()?;\n            Some((f64::from(value) / f64::from(u16::MAX) * 255.0).round() as u8)\n        }\n        len if len > 0 => {\n            let value = u128::from_str_radix(hex, 16).ok()?;\n            let max = (16_u128).pow(len as u32) - 1;\n            Some(((value as f64 / max as f64) * 255.0).round() as u8)\n        }\n        _ => None,\n    }\n}\n\n#[cfg(unix)]\nfn parse_rgb_triplet(input: &str) -> Option<Rgb> {\n    let mut parts = input.split('/');\n    let r_hex = parts.next()?;\n    let g_hex = parts.next()?;\n    let b_raw = parts.next()?;\n    let b_hex = b_raw.chars().take_while(|c| c.is_ascii_hexdigit()).collect::<String>();\n\n    Some(Rgb(to_8bit(r_hex)?, to_8bit(g_hex)?, to_8bit(&b_hex)?))\n}\n\n#[cfg(unix)]\nfn parse_osc10_rgb(buffer: &str) -> Option<Rgb> {\n    let start = buffer.find(\"\\x1b]10;\")?;\n    let tail = &buffer[start..];\n    let rgb_start = tail.find(\"rgb:\")?;\n    parse_rgb_triplet(&tail[rgb_start + 4..])\n}\n\n#[cfg(unix)]\nfn parse_osc4_rgb(buffer: &str, index: u8) -> Option<Rgb> {\n    let prefix = format!(\"\\x1b]4;{index};\");\n    let start = buffer.find(&prefix)?;\n    let tail = &buffer[start + prefix.len()..];\n    let rgb_start = tail.find(\"rgb:\")?;\n    parse_rgb_triplet(&tail[rgb_start + 4..])\n}\n\n/// Returns `true` if the terminal is known to not support OSC color queries\n/// or if the environment is unreliable for escape-sequence round-trips.\n///\n/// Modelled after `terminal-colorsaurus`'s quirks detection, extended with\n/// additional checks for Docker, CI, devcontainers, and other environments.\n#[cfg(unix)]\nfn is_osc_query_unsupported() -> bool {\n    static UNSUPPORTED: OnceLock<bool> = OnceLock::new();\n    *UNSUPPORTED.get_or_init(|| {\n        if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() {\n            return true;\n        }\n\n        // CI environments have no real terminal emulator behind the PTY.\n        if std::env::var_os(\"CI\").is_some() || std::env::var_os(\"GITHUB_ACTIONS\").is_some() {\n            return true;\n        }\n\n        // Warp terminal does not respond to OSC color queries in its\n        // block-mode renderer, causing a hang until the user presses a key.\n        if is_warp_terminal() {\n            return true;\n        }\n\n        // Emacs terminal emulators (ansi-term, vterm, eshell) don't support\n        // OSC queries.\n        if std::env::var_os(\"INSIDE_EMACS\").is_some() {\n            return true;\n        }\n\n        // Docker containers and devcontainers may have a PTY with no real\n        // terminal emulator, causing OSC responses to leak as visible text.\n        if std::path::Path::new(\"/.dockerenv\").exists()\n            || std::env::var_os(\"REMOTE_CONTAINERS\").is_some()\n            || std::env::var_os(\"CODESPACES\").is_some()\n            || std::env::var_os(\"KUBERNETES_SERVICE_HOST\").is_some()\n        {\n            return true;\n        }\n\n        match std::env::var(\"TERM\") {\n            // Missing or non-unicode TERM is highly suspect.\n            Err(_) => return true,\n            // `TERM=dumb` indicates a minimal terminal with no escape support.\n            Ok(term) if term == \"dumb\" => return true,\n            // GNU Screen responds to OSC queries in the wrong order, breaking\n            // the DA1 sandwich technique. It also only supports OSC 11, not\n            // OSC 10 or OSC 4.\n            Ok(term) if term == \"screen\" || term.starts_with(\"screen.\") => return true,\n            // Eterm doesn't support DA1, so we skip to avoid the timeout.\n            Ok(term) if term == \"Eterm\" => return true,\n            _ => {}\n        }\n\n        // tmux and GNU Screen (via STY) do not reliably forward OSC color\n        // query responses back to the child process.\n        if std::env::var_os(\"TMUX\").is_some() || std::env::var_os(\"STY\").is_some() {\n            return true;\n        }\n\n        false\n    })\n}\n\n/// DA1 (Primary Device Attributes) query — supported by virtually all\n/// terminals. Used as a sentinel in the \"DA1 sandwich\" technique:\n/// we send our OSC queries followed by DA1, then read responses. If the\n/// DA1 response (`ESC [ ? ...`) arrives first, the terminal doesn't\n/// support OSC queries and we bail out immediately instead of waiting\n/// for a timeout.\n#[cfg(unix)]\nconst DA1: &str = \"\\x1b[c\";\n\n/// Reads from a `BufRead` until one of two delimiter bytes is found.\n/// Modelled after `terminal-colorsaurus`'s `read_until2`.\n#[cfg(unix)]\nfn read_until_either(\n    r: &mut impl std::io::BufRead,\n    d1: u8,\n    d2: u8,\n    buf: &mut Vec<u8>,\n) -> std::io::Result<usize> {\n    let mut total = 0;\n    loop {\n        let available = match r.fill_buf() {\n            Ok(b) => b,\n            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,\n            Err(e) => return Err(e),\n        };\n        if available.is_empty() {\n            return Ok(total);\n        }\n        if let Some(i) = available.iter().position(|&b| b == d1 || b == d2) {\n            buf.extend_from_slice(&available[..=i]);\n            let used = i + 1;\n            r.consume(used);\n            total += used;\n            return Ok(total);\n        }\n        let len = available.len();\n        buf.extend_from_slice(available);\n        r.consume(len);\n        total += len;\n    }\n}\n\n/// Queries terminal colors using the DA1 sandwich technique with\n/// stream-based response parsing (modelled after `terminal-colorsaurus`).\n///\n/// Responses are read sequentially using `BufReader` + `read_until`,\n/// which provides exact response boundaries and eliminates the\n/// ordering/completeness ambiguities of flat-buffer pattern matching.\n#[cfg(unix)]\nfn query_terminal_colors(palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>) {\n    use std::{\n        fs::OpenOptions,\n        io::{self, BufRead, BufReader},\n        os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd},\n    };\n\n    use nix::{\n        poll::{PollFd, PollFlags, PollTimeout, poll},\n        sys::termios::{SetArg, Termios, cfmakeraw, tcgetattr, tcsetattr},\n    };\n\n    if is_osc_query_unsupported() {\n        return (None, vec![]);\n    }\n\n    let mut tty = match OpenOptions::new().read(true).write(true).open(\"/dev/tty\") {\n        Ok(file) => file,\n        Err(_) => return (None, vec![]),\n    };\n\n    struct RawGuard {\n        fd: RawFd,\n        original: Termios,\n    }\n\n    impl Drop for RawGuard {\n        fn drop(&mut self) {\n            // SAFETY: `fd` comes from an open `/dev/tty` and the guard does not outlive that file.\n            let borrowed = unsafe { BorrowedFd::borrow_raw(self.fd) };\n            let _ = tcsetattr(borrowed, SetArg::TCSANOW, &self.original);\n        }\n    }\n\n    let original = match tcgetattr(tty.as_fd()) {\n        Ok(value) => value,\n        Err(_) => return (None, vec![]),\n    };\n    let mut raw = original.clone();\n    cfmakeraw(&mut raw);\n    if tcsetattr(tty.as_fd(), SetArg::TCSANOW, &raw).is_err() {\n        return (None, vec![]);\n    }\n    // `_guard` is declared after `tty` so it drops first (reverse declaration\n    // order), restoring terminal mode while the fd is still open.\n    let _guard = RawGuard { fd: tty.as_raw_fd(), original };\n\n    // Build the query: OSC 10 (foreground) + OSC 4 (palette) + DA1 (sentinel).\n    // BEL (\\x07) is used as string terminator instead of ST (\\x1b\\\\) because\n    // urxvt has a bug where it terminates responses with bare ESC instead of\n    // ST, causing a parse hang. BEL-terminated queries produce BEL-terminated\n    // responses, avoiding this issue.\n    let mut query = format!(\"{ESC}]10;?\\x07\");\n    for index in palette_indices {\n        query.push_str(&format!(\"{ESC}]4;{index};?\\x07\"));\n    }\n    query.push_str(DA1);\n\n    if tty.write_all(query.as_bytes()).is_err() {\n        return (None, vec![]);\n    }\n    if tty.flush().is_err() {\n        return (None, vec![]);\n    }\n\n    // Use a longer timeout for SSH to account for round-trip latency.\n    let timeout_ms =\n        if std::env::var_os(\"SSH_CONNECTION\").is_some() || std::env::var_os(\"SSH_TTY\").is_some() {\n            1000\n        } else {\n            200\n        };\n    let deadline = Instant::now() + Duration::from_millis(timeout_ms);\n\n    // Timeout-aware reader: polls for readability before each read,\n    // returning `TimedOut` when the deadline expires. Wrapping in\n    // `BufReader` gives us `read_until` and `fill_buf`/`buffer` for\n    // delimiter-based parsing with peek-ahead.\n    struct TtyReader<'a> {\n        tty: &'a std::fs::File,\n        deadline: Instant,\n    }\n\n    impl io::Read for TtyReader<'_> {\n        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {\n            let remaining = self.deadline.saturating_duration_since(Instant::now());\n            if remaining.is_zero() {\n                return Err(io::Error::new(io::ErrorKind::TimedOut, \"tty read timed out\"));\n            }\n            let mut fds = [PollFd::new(self.tty.as_fd(), PollFlags::POLLIN)];\n            let timeout = PollTimeout::try_from(remaining)\n                .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, \"tty read timed out\"))?;\n            let ready = poll(&mut fds, timeout).map_err(io::Error::from)?;\n            if ready == 0 {\n                return Err(io::Error::new(io::ErrorKind::TimedOut, \"tty read timed out\"));\n            }\n            io::Read::read(&mut &*self.tty, buf)\n        }\n    }\n\n    let tty_reader = TtyReader { tty: &tty, deadline };\n    let mut reader = BufReader::with_capacity(64, tty_reader);\n\n    const ESC_BYTE: u8 = 0x1b;\n    const BEL_BYTE: u8 = 0x07;\n\n    // Read a single OSC response from the stream. Returns:\n    //   Ok(bytes)  — an OSC response (ESC ] ... BEL/ST)\n    //   Err(true)  — DA1 response arrived (terminal doesn't support this query)\n    //   Err(false) — timeout or I/O error\n    //\n    // This mirrors `terminal-colorsaurus`'s `read_color_response`: read until\n    // ESC, peek at the next byte to distinguish OSC (']') from DA1 ('['),\n    // then read until the response terminator.\n    let read_osc_response = |r: &mut BufReader<TtyReader>| -> Result<Vec<u8>, bool> {\n        let mut buf = Vec::new();\n\n        // Read until ESC — start of next response.\n        r.read_until(ESC_BYTE, &mut buf).map_err(|_| false)?;\n\n        // Peek at the next byte in BufReader's internal buffer.\n        // ']' = OSC response, '[' = DA1/CSI response.\n        let next = match r.fill_buf() {\n            Ok(b) if !b.is_empty() => b[0],\n            _ => return Err(false),\n        };\n\n        if next != b']' {\n            // DA1 response (ESC [ ? ... c). Consume it so it doesn't leak.\n            let mut discard = Vec::new();\n            let _ = r.read_until(b'[', &mut discard);\n            let _ = r.read_until(b'c', &mut discard);\n            return Err(true);\n        }\n\n        // OSC response — read until BEL or ESC (for ST termination).\n        read_until_either(r, BEL_BYTE, ESC_BYTE, &mut buf).map_err(|_| false)?;\n        if buf.last() == Some(&ESC_BYTE) {\n            // ST-terminated: ESC followed by '\\'.\n            r.read_until(b'\\\\', &mut buf).map_err(|_| false)?;\n        }\n\n        Ok(buf)\n    };\n\n    // Read foreground color (OSC 10 response).\n    let foreground = match read_osc_response(&mut reader) {\n        Ok(data) => {\n            let s = String::from_utf8_lossy(&data);\n            parse_osc10_rgb(&s)\n        }\n        Err(true) => return (None, vec![]), // DA1 first → unsupported\n        Err(false) => return (None, vec![]), // timeout/error\n    };\n\n    // Read palette colors (OSC 4 responses).\n    let mut palette_results = Vec::new();\n    let mut da1_consumed = false;\n    for &index in palette_indices {\n        match read_osc_response(&mut reader) {\n            Ok(data) => {\n                let s = String::from_utf8_lossy(&data);\n                if let Some(rgb) = parse_osc4_rgb(&s, index) {\n                    palette_results.push((index, rgb));\n                }\n            }\n            Err(is_da1) => {\n                da1_consumed = is_da1;\n                break;\n            }\n        }\n    }\n\n    // Drain the trailing DA1 response (ESC [ ? ... c) so it doesn't leak.\n    // Skip if the DA1 was already consumed inside read_osc_response.\n    if !da1_consumed {\n        let mut discard = Vec::new();\n        let _ = reader.read_until(ESC_BYTE, &mut discard);\n        let _ = reader.read_until(b'[', &mut discard);\n        let _ = reader.read_until(b'c', &mut discard);\n    }\n\n    (foreground, palette_results)\n}\n\n#[cfg(not(unix))]\nfn query_terminal_colors(_palette_indices: &[u8]) -> (Option<Rgb>, Vec<(u8, Rgb)>) {\n    (None, vec![])\n}\n\nfn palette_color(palette: &[(u8, Rgb)], index: u8) -> Option<Rgb> {\n    palette.iter().find_map(|(palette_index, color)| (*palette_index == index).then_some(*color))\n}\n\nfn get_header_colors() -> &'static HeaderColors {\n    HEADER_COLORS.get_or_init(|| {\n        let (foreground, palette) = query_terminal_colors(&[ANSI_BLUE_INDEX, ANSI_MAGENTA_INDEX]);\n        let blue = palette_color(&palette, ANSI_BLUE_INDEX).unwrap_or(DEFAULT_BLUE);\n        let magenta = palette_color(&palette, ANSI_MAGENTA_INDEX).unwrap_or(DEFAULT_MAGENTA);\n\n        let suffix_gradient = match foreground {\n            Some(color) => gradient_three_stop(\n                HEADER_SUFFIX.chars().count(),\n                blue,\n                magenta,\n                color,\n                HEADER_SUFFIX_FADE_GAMMA,\n            ),\n            None => gradient_eased(\n                HEADER_SUFFIX.chars().count(),\n                blue,\n                magenta,\n                HEADER_SUFFIX_FADE_GAMMA,\n            ),\n        };\n\n        HeaderColors { blue, suffix_gradient }\n    })\n}\n\nfn render_header_variant(\n    primary: Rgb,\n    suffix_colors: &[Rgb],\n    prefix_bold: bool,\n    suffix_bold: bool,\n) -> String {\n    let vite_plus = format!(\"{}VITE+{RESET_FG}\", fg_rgb(primary));\n    let suffix = colorize(HEADER_SUFFIX, suffix_colors);\n    format!(\"{}{}\", bold(&vite_plus, prefix_bold), bold(&suffix, suffix_bold))\n}\n\n/// Render the Vite+ CLI header string with JS-parity coloring behavior.\n#[must_use]\npub fn vite_plus_header() -> String {\n    if !should_colorize() || !supports_true_color() {\n        return format!(\"VITE+{HEADER_SUFFIX}\");\n    }\n\n    let header_colors = get_header_colors();\n    render_header_variant(header_colors.blue, &header_colors.suffix_gradient, true, true)\n}\n\n#[cfg(all(test, unix))]\nmod tests {\n    use std::io::{BufReader, Cursor};\n\n    use super::{\n        Rgb, gradient_eased, parse_osc4_rgb, parse_osc10_rgb, parse_rgb_triplet,\n        query_terminal_colors, read_until_either, to_8bit,\n    };\n\n    #[test]\n    fn to_8bit_matches_js_rules() {\n        assert_eq!(to_8bit(\"ff\"), Some(255));\n        assert_eq!(to_8bit(\"7f\"), Some(127));\n        assert_eq!(to_8bit(\"ffff\"), Some(255));\n        assert_eq!(to_8bit(\"0000\"), Some(0));\n        assert_eq!(to_8bit(\"fff\"), Some(255));\n    }\n\n    #[test]\n    fn to_8bit_single_digit() {\n        assert_eq!(to_8bit(\"f\"), Some(255));\n        assert_eq!(to_8bit(\"0\"), Some(0));\n        assert_eq!(to_8bit(\"a\"), Some(170));\n    }\n\n    #[test]\n    fn to_8bit_three_digit() {\n        assert_eq!(to_8bit(\"fff\"), Some(255));\n        assert_eq!(to_8bit(\"000\"), Some(0));\n        assert_eq!(to_8bit(\"800\"), Some(128));\n    }\n\n    #[test]\n    fn to_8bit_empty_returns_none() {\n        assert_eq!(to_8bit(\"\"), None);\n    }\n\n    #[test]\n    fn to_8bit_invalid_hex_returns_none() {\n        assert_eq!(to_8bit(\"zz\"), None);\n        assert_eq!(to_8bit(\"gg\"), None);\n    }\n\n    #[test]\n    fn parse_rgb_triplet_standard() {\n        assert_eq!(parse_rgb_triplet(\"ff/ff/ff\"), Some(Rgb(255, 255, 255)));\n        assert_eq!(parse_rgb_triplet(\"00/00/00\"), Some(Rgb(0, 0, 0)));\n    }\n\n    #[test]\n    fn parse_rgb_triplet_four_digit_channels() {\n        assert_eq!(parse_rgb_triplet(\"ffff/ffff/ffff\"), Some(Rgb(255, 255, 255)));\n        assert_eq!(parse_rgb_triplet(\"0000/0000/0000\"), Some(Rgb(0, 0, 0)));\n        assert_eq!(parse_rgb_triplet(\"aaaa/bbbb/cccc\"), Some(Rgb(170, 187, 204)));\n    }\n\n    #[test]\n    fn parse_rgb_triplet_mixed_digit_channels() {\n        // Single digit channels\n        assert_eq!(parse_rgb_triplet(\"f/e/d\"), Some(Rgb(255, 238, 221)));\n    }\n\n    #[test]\n    fn parse_rgb_triplet_trailing_junk_ignored() {\n        // The parser stops at non-hex chars for the blue channel\n        assert_eq!(parse_rgb_triplet(\"ff/ff/ff\\x1b\\\\\"), Some(Rgb(255, 255, 255)));\n    }\n\n    #[test]\n    fn parse_rgb_triplet_missing_channel_returns_none() {\n        assert_eq!(parse_rgb_triplet(\"ff/ff\"), None);\n        assert_eq!(parse_rgb_triplet(\"ff\"), None);\n    }\n\n    #[test]\n    fn parse_osc10_response_extracts_rgb() {\n        let response = \"\\x1b]10;rgb:aaaa/bbbb/cccc\\x1b\\\\\";\n        assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204)));\n    }\n\n    #[test]\n    fn parse_osc10_bel_terminated() {\n        let response = \"\\x1b]10;rgb:aaaa/bbbb/cccc\\x07\";\n        assert_eq!(parse_osc10_rgb(response), Some(Rgb(170, 187, 204)));\n    }\n\n    #[test]\n    fn parse_osc10_no_match_returns_none() {\n        assert_eq!(parse_osc10_rgb(\"garbage\"), None);\n        assert_eq!(parse_osc10_rgb(\"\"), None);\n    }\n\n    #[test]\n    fn parse_osc4_response_extracts_rgb() {\n        let response = \"\\x1b]4;5;rgb:aaaa/bbbb/cccc\\x1b\\\\\";\n        assert_eq!(parse_osc4_rgb(response, 5), Some(Rgb(170, 187, 204)));\n    }\n\n    #[test]\n    fn parse_osc4_bel_terminated() {\n        let response = \"\\x1b]4;4;rgb:5858/9292/ffff\\x07\";\n        assert_eq!(parse_osc4_rgb(response, 4), Some(Rgb(88, 146, 255)));\n    }\n\n    #[test]\n    fn parse_osc4_wrong_index_returns_none() {\n        let response = \"\\x1b]4;5;rgb:aaaa/bbbb/cccc\\x1b\\\\\";\n        assert_eq!(parse_osc4_rgb(response, 4), None);\n    }\n\n    #[test]\n    fn parse_osc4_no_match_returns_none() {\n        assert_eq!(parse_osc4_rgb(\"garbage\", 5), None);\n        assert_eq!(parse_osc4_rgb(\"\", 0), None);\n    }\n\n    #[test]\n    fn parse_osc_multiple_responses_in_buffer() {\n        // Simulates a buffer containing OSC 10 + OSC 4;4 + OSC 4;5 responses\n        let buffer = \"\\x1b]10;rgb:d0d0/d0d0/d0d0\\x07\\\n                       \\x1b]4;4;rgb:5858/9292/ffff\\x07\\\n                       \\x1b]4;5;rgb:bbbb/7474/f7f7\\x07\";\n        assert_eq!(parse_osc10_rgb(buffer), Some(Rgb(208, 208, 208)));\n        assert_eq!(parse_osc4_rgb(buffer, 4), Some(Rgb(88, 146, 255)));\n        assert_eq!(parse_osc4_rgb(buffer, 5), Some(Rgb(187, 116, 247)));\n    }\n\n    #[test]\n    fn parse_osc_buffer_with_da1_response() {\n        // DA1 response mixed in — OSC parsers should still find their data\n        let buffer = \"\\x1b]10;rgb:d0d0/d0d0/d0d0\\x07\\x1b[?64;1;2;4c\";\n        assert_eq!(parse_osc10_rgb(buffer), Some(Rgb(208, 208, 208)));\n    }\n\n    #[test]\n    fn gradient_counts_match() {\n        assert_eq!(gradient_eased(0, Rgb(0, 0, 0), Rgb(255, 255, 255), 1.0).len(), 1);\n        assert_eq!(gradient_eased(5, Rgb(10, 20, 30), Rgb(40, 50, 60), 1.0).len(), 5);\n    }\n\n    /// Regression test ported from terminal-colorsaurus (issue #38).\n    /// In CI there is no real terminal, so `query_terminal_colors` must\n    /// return `(None, vec![])` without hanging.\n    #[test]\n    fn query_terminal_colors_does_not_hang() {\n        let (fg, palette) = query_terminal_colors(&[4, 5]);\n        // In CI, the environment pre-screening or DA1 sandwich will cause an\n        // early return. We don't assert specific values — just that it\n        // completes promptly and doesn't panic.\n        let _ = (fg, palette);\n    }\n\n    #[test]\n    fn read_until_either_stops_at_first_delimiter() {\n        let data = b\"hello\\x07world\";\n        let mut reader = BufReader::new(Cursor::new(data.as_slice()));\n        let mut buf = Vec::new();\n        let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap();\n        assert_eq!(n, 6); // \"hello\" + BEL\n        assert_eq!(&buf, b\"hello\\x07\");\n    }\n\n    #[test]\n    fn read_until_either_stops_at_second_delimiter() {\n        let data = b\"hello\\x1bworld\";\n        let mut reader = BufReader::new(Cursor::new(data.as_slice()));\n        let mut buf = Vec::new();\n        let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap();\n        assert_eq!(n, 6); // \"hello\" + ESC\n        assert_eq!(&buf, b\"hello\\x1b\");\n    }\n\n    #[test]\n    fn read_until_either_no_delimiter_reads_all() {\n        let data = b\"hello world\";\n        let mut reader = BufReader::new(Cursor::new(data.as_slice()));\n        let mut buf = Vec::new();\n        let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap();\n        assert_eq!(n, 11);\n        assert_eq!(&buf, b\"hello world\");\n    }\n\n    #[test]\n    fn read_until_either_empty_input() {\n        let data: &[u8] = b\"\";\n        let mut reader = BufReader::new(Cursor::new(data));\n        let mut buf = Vec::new();\n        let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap();\n        assert_eq!(n, 0);\n        assert!(buf.is_empty());\n    }\n\n    #[test]\n    fn read_until_either_delimiter_at_start() {\n        let data = b\"\\x07rest\";\n        let mut reader = BufReader::new(Cursor::new(data.as_slice()));\n        let mut buf = Vec::new();\n        let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap();\n        assert_eq!(n, 1);\n        assert_eq!(&buf, b\"\\x07\");\n    }\n\n    #[test]\n    fn read_until_either_multi_chunk() {\n        // Use a tiny BufReader capacity to force multiple fill_buf calls.\n        let data = b\"abcdefgh\\x07rest\";\n        let mut reader = BufReader::with_capacity(3, Cursor::new(data.as_slice()));\n        let mut buf = Vec::new();\n        let n = read_until_either(&mut reader, 0x07, 0x1b, &mut buf).unwrap();\n        assert_eq!(n, 9); // \"abcdefgh\" + BEL\n        assert_eq!(&buf, b\"abcdefgh\\x07\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/src/home.rs",
    "content": "use directories::BaseDirs;\nuse vite_path::{AbsolutePathBuf, current_dir};\n\nuse crate::EnvConfig;\n\n/// Default VITE_PLUS_HOME directory name\nconst VITE_PLUS_HOME_DIR: &str = \".vite-plus\";\n\n/// Get the vite-plus home directory.\n///\n/// Uses `EnvConfig::get().vite_plus_home` if set, otherwise defaults to `~/.vite-plus`.\n/// Falls back to `$CWD/.vite-plus` if the home directory cannot be determined.\npub fn get_vite_plus_home() -> std::io::Result<AbsolutePathBuf> {\n    let config = EnvConfig::get();\n    if let Some(ref home) = config.vite_plus_home {\n        if let Some(path) = AbsolutePathBuf::new(home.clone()) {\n            return Ok(path);\n        }\n    }\n\n    // Default to ~/.vite-plus\n    match BaseDirs::new() {\n        Some(dirs) => {\n            let home = AbsolutePathBuf::new(dirs.home_dir().to_path_buf()).unwrap();\n            Ok(home.join(VITE_PLUS_HOME_DIR))\n        }\n        None => {\n            // Fallback to $CWD/.vite-plus\n            Ok(current_dir()?.join(VITE_PLUS_HOME_DIR))\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_get_vite_plus_home() {\n        let home = get_vite_plus_home().unwrap();\n        assert!(home.ends_with(\".vite-plus\"));\n    }\n\n    #[test]\n    fn test_get_vite_plus_home_with_custom_path() {\n        let temp_dir = std::env::temp_dir().join(\"vp-test-custom-home\");\n        EnvConfig::test_scope(EnvConfig::for_test_with_home(&temp_dir), || {\n            let home = get_vite_plus_home().unwrap();\n            assert_eq!(home.as_path(), temp_dir.as_path());\n        });\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/src/lib.rs",
    "content": "//! Shared utilities for vite-plus crates\n\nmod env_config;\npub mod env_vars;\npub mod header;\nmod home;\npub mod output;\nmod package_json;\nmod path_env;\npub mod string_similarity;\nmod tracing;\n\npub use env_config::{EnvConfig, TestEnvGuard};\npub use home::get_vite_plus_home;\npub use package_json::{DevEngines, Engines, PackageJson, RuntimeEngine, RuntimeEngineConfig};\npub use path_env::{\n    PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend,\n    prepend_to_path_env,\n};\npub use tracing::init_tracing;\n"
  },
  {
    "path": "crates/vite_shared/src/output.rs",
    "content": "//! Shared CLI output formatting for consistent message prefixes and status symbols.\n//!\n//! All commands should use these functions instead of ad-hoc formatting to ensure\n//! consistent output across the entire CLI.\n\nuse owo_colors::OwoColorize;\n\n// Standard status symbols\n/// Success checkmark: ✓\npub const CHECK: &str = \"\\u{2713}\";\n/// Failure cross: ✗\npub const CROSS: &str = \"\\u{2717}\";\n/// Warning sign: ⚠\npub const WARN_SIGN: &str = \"\\u{26A0}\";\n/// Right arrow: →\npub const ARROW: &str = \"\\u{2192}\";\n\n/// Print an info message to stdout.\n#[allow(clippy::print_stdout, clippy::disallowed_macros)]\npub fn info(msg: &str) {\n    println!(\"{} {msg}\", \"info:\".bright_blue().bold());\n}\n\n/// Print a pass message to stdout using the same accent styling as info.\n#[allow(clippy::print_stdout, clippy::disallowed_macros)]\npub fn pass(msg: &str) {\n    println!(\"{} {msg}\", \"pass:\".bright_blue().bold());\n}\n\n/// Print a warning message to stderr.\n#[allow(clippy::print_stderr, clippy::disallowed_macros)]\npub fn warn(msg: &str) {\n    eprintln!(\"{} {msg}\", \"warn:\".yellow().bold());\n}\n\n/// Print an error message to stderr.\n#[allow(clippy::print_stderr, clippy::disallowed_macros)]\npub fn error(msg: &str) {\n    eprintln!(\"{} {msg}\", \"error:\".red().bold());\n}\n\n/// Print a note message to stdout (supplementary info).\n#[allow(clippy::print_stdout, clippy::disallowed_macros)]\npub fn note(msg: &str) {\n    println!(\"{} {msg}\", \"note:\".dimmed().bold());\n}\n\n/// Print a success line with checkmark to stdout.\n#[allow(clippy::print_stdout, clippy::disallowed_macros)]\npub fn success(msg: &str) {\n    println!(\"{} {msg}\", CHECK.green());\n}\n\n/// Print a raw message to stdout with no prefix or formatting.\n#[allow(clippy::print_stdout, clippy::disallowed_macros)]\npub fn raw(msg: &str) {\n    println!(\"{msg}\");\n}\n\n/// Print a raw message to stdout without a trailing newline.\n#[allow(clippy::print_stdout, clippy::disallowed_macros)]\npub fn raw_inline(msg: &str) {\n    print!(\"{msg}\");\n}\n"
  },
  {
    "path": "crates/vite_shared/src/package_json.rs",
    "content": "//! Package.json parsing utilities for Node.js version resolution.\n//!\n//! This module provides shared types for parsing `devEngines.runtime` and `engines.node`\n//! fields from package.json, used across multiple crates for version resolution.\n\nuse serde::Deserialize;\nuse vite_str::Str;\n\n/// A single runtime engine configuration.\n#[derive(Deserialize, Default, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct RuntimeEngine {\n    /// The name of the runtime (e.g., \"node\", \"deno\", \"bun\")\n    #[serde(default)]\n    pub name: Str,\n    /// The version requirement (e.g., \"^24.4.0\")\n    #[serde(default)]\n    pub version: Str,\n    /// Action to take on failure (e.g., \"download\", \"error\", \"warn\")\n    /// Currently not used but parsed for future use.\n    #[serde(default)]\n    pub on_fail: Str,\n}\n\n/// Runtime field can be a single object or an array.\n#[derive(Deserialize, Debug, Clone)]\n#[serde(untagged)]\npub enum RuntimeEngineConfig {\n    /// A single runtime configuration\n    Single(RuntimeEngine),\n    /// Multiple runtime configurations\n    Multiple(Vec<RuntimeEngine>),\n}\n\nimpl RuntimeEngineConfig {\n    /// Find the first runtime with the given name.\n    #[must_use]\n    pub fn find_by_name(&self, name: &str) -> Option<&RuntimeEngine> {\n        match self {\n            Self::Single(engine) if engine.name == name => Some(engine),\n            Self::Single(_) => None,\n            Self::Multiple(engines) => engines.iter().find(|e| e.name == name),\n        }\n    }\n}\n\n/// The devEngines section of package.json.\n#[derive(Deserialize, Default, Debug, Clone)]\npub struct DevEngines {\n    /// Runtime configuration(s)\n    #[serde(default)]\n    pub runtime: Option<RuntimeEngineConfig>,\n}\n\n/// The engines section of package.json.\n#[derive(Deserialize, Default, Debug, Clone)]\npub struct Engines {\n    /// Node.js version requirement (e.g., \">=20.0.0\")\n    #[serde(default)]\n    pub node: Option<Str>,\n}\n\n/// Partial package.json structure for reading devEngines and engines.\n#[derive(Deserialize, Default, Debug, Clone)]\n#[serde(rename_all = \"camelCase\")]\npub struct PackageJson {\n    /// The devEngines configuration\n    #[serde(default)]\n    pub dev_engines: Option<DevEngines>,\n    /// The engines configuration\n    #[serde(default)]\n    pub engines: Option<Engines>,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_single_runtime() {\n        let json = r#\"{\n            \"devEngines\": {\n                \"runtime\": {\n                    \"name\": \"node\",\n                    \"version\": \"^24.4.0\",\n                    \"onFail\": \"download\"\n                }\n            }\n        }\"#;\n\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        let dev_engines = pkg.dev_engines.unwrap();\n        let runtime = dev_engines.runtime.unwrap();\n\n        let node = runtime.find_by_name(\"node\").unwrap();\n        assert_eq!(node.name, \"node\");\n        assert_eq!(node.version, \"^24.4.0\");\n        assert_eq!(node.on_fail, \"download\");\n\n        assert!(runtime.find_by_name(\"deno\").is_none());\n    }\n\n    #[test]\n    fn test_parse_multiple_runtimes() {\n        let json = r#\"{\n            \"devEngines\": {\n                \"runtime\": [\n                    {\n                        \"name\": \"node\",\n                        \"version\": \"^24.4.0\",\n                        \"onFail\": \"download\"\n                    },\n                    {\n                        \"name\": \"deno\",\n                        \"version\": \"^2.4.3\",\n                        \"onFail\": \"download\"\n                    }\n                ]\n            }\n        }\"#;\n\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        let dev_engines = pkg.dev_engines.unwrap();\n        let runtime = dev_engines.runtime.unwrap();\n\n        let node = runtime.find_by_name(\"node\").unwrap();\n        assert_eq!(node.name, \"node\");\n        assert_eq!(node.version, \"^24.4.0\");\n\n        let deno = runtime.find_by_name(\"deno\").unwrap();\n        assert_eq!(deno.name, \"deno\");\n        assert_eq!(deno.version, \"^2.4.3\");\n\n        assert!(runtime.find_by_name(\"bun\").is_none());\n    }\n\n    #[test]\n    fn test_parse_no_dev_engines() {\n        let json = r#\"{\"name\": \"test\"}\"#;\n\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        assert!(pkg.dev_engines.is_none());\n    }\n\n    #[test]\n    fn test_parse_empty_dev_engines() {\n        let json = r#\"{\"devEngines\": {}}\"#;\n\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        let dev_engines = pkg.dev_engines.unwrap();\n        assert!(dev_engines.runtime.is_none());\n    }\n\n    #[test]\n    fn test_parse_runtime_with_missing_fields() {\n        let json = r#\"{\n            \"devEngines\": {\n                \"runtime\": {\n                    \"name\": \"node\"\n                }\n            }\n        }\"#;\n\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        let dev_engines = pkg.dev_engines.unwrap();\n        let runtime = dev_engines.runtime.unwrap();\n\n        let node = runtime.find_by_name(\"node\").unwrap();\n        assert_eq!(node.name, \"node\");\n        assert!(node.version.is_empty());\n        assert!(node.on_fail.is_empty());\n    }\n\n    #[test]\n    fn test_parse_engines_node() {\n        let json = r#\"{\"engines\":{\"node\":\">=20.0.0\"}}\"#;\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        assert_eq!(pkg.engines.unwrap().node, Some(\">=20.0.0\".into()));\n    }\n\n    #[test]\n    fn test_parse_engines_node_empty() {\n        let json = r#\"{\"engines\":{}}\"#;\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        assert!(pkg.engines.unwrap().node.is_none());\n    }\n\n    #[test]\n    fn test_parse_both_engines_and_dev_engines() {\n        let json = r#\"{\n            \"engines\": {\"node\": \">=20.0.0\"},\n            \"devEngines\": {\"runtime\": {\"name\": \"node\", \"version\": \"^24.4.0\"}}\n        }\"#;\n        let pkg: PackageJson = serde_json::from_str(json).unwrap();\n        assert_eq!(pkg.engines.unwrap().node, Some(\">=20.0.0\".into()));\n        let dev_engines = pkg.dev_engines.unwrap();\n        let runtime = dev_engines.runtime.unwrap();\n        let node = runtime.find_by_name(\"node\").unwrap();\n        assert_eq!(node.version, \"^24.4.0\");\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/src/path_env.rs",
    "content": "//! PATH environment variable manipulation utilities.\n//!\n//! This module provides functions for prepending directories to the PATH\n//! environment variable with various deduplication strategies.\n\nuse std::{env, ffi::OsString, path::Path};\n\nuse vite_path::AbsolutePath;\n\n/// Options for deduplication behavior when prepending to PATH.\n#[derive(Debug, Clone, Copy, Default)]\npub struct PrependOptions {\n    /// If `false`, only check if the directory is first in PATH (faster).\n    /// If `true`, check if the directory exists anywhere in PATH.\n    pub dedupe_anywhere: bool,\n}\n\n/// Result of a PATH prepend operation.\n#[derive(Debug)]\npub enum PrependResult {\n    /// The directory was prepended successfully.\n    Prepended(OsString),\n    /// The directory is already present in PATH (based on dedup strategy).\n    AlreadyPresent,\n    /// Failed to join paths (invalid characters in path).\n    JoinError,\n}\n\n/// Format PATH with the given directory prepended.\n///\n/// This returns a new PATH value without modifying the environment.\n/// Use this when you need to set PATH on a `Command` via `cmd.env()`.\n///\n/// # Arguments\n/// * `dir` - The directory to prepend to PATH\n/// * `options` - Deduplication options\n///\n/// # Returns\n/// * `PrependResult::Prepended(new_path)` - The new PATH value with directory prepended\n/// * `PrependResult::AlreadyPresent` - Directory already exists in PATH (based on options)\n/// * `PrependResult::JoinError` - Failed to join paths\npub fn format_path_with_prepend(dir: impl AsRef<Path>, options: PrependOptions) -> PrependResult {\n    let dir = dir.as_ref();\n    let current_path = env::var_os(\"PATH\").unwrap_or_default();\n    let paths: Vec<_> = env::split_paths(&current_path).collect();\n\n    // Check for duplicates based on strategy\n    if options.dedupe_anywhere {\n        if paths.iter().any(|p| p == dir) {\n            return PrependResult::AlreadyPresent;\n        }\n    } else if let Some(first) = paths.first() {\n        if first == dir {\n            return PrependResult::AlreadyPresent;\n        }\n    }\n\n    // Prepend the directory\n    let mut new_paths = vec![dir.to_path_buf()];\n    new_paths.extend(paths);\n\n    match env::join_paths(new_paths) {\n        Ok(new_path) => PrependResult::Prepended(new_path),\n        Err(_) => PrependResult::JoinError,\n    }\n}\n\n/// Prepend a directory to the global PATH environment variable.\n///\n/// This modifies the process environment using `std::env::set_var`.\n///\n/// # Safety\n/// This function uses `unsafe` to call `std::env::set_var`, which is unsafe\n/// in multi-threaded contexts. Only call this before spawning threads or\n/// when you're certain no other threads are reading environment variables.\n///\n/// # Arguments\n/// * `dir` - The directory to prepend to PATH\n/// * `options` - Deduplication options\n///\n/// # Returns\n/// * `true` if PATH was modified\n/// * `false` if the directory was already present or join failed\npub fn prepend_to_path_env(dir: &AbsolutePath, options: PrependOptions) -> bool {\n    match format_path_with_prepend(dir.as_path(), options) {\n        PrependResult::Prepended(new_path) => {\n            // SAFETY: Caller ensures this is safe (single-threaded or before exec)\n            unsafe { env::set_var(\"PATH\", new_path) };\n            true\n        }\n        PrependResult::AlreadyPresent | PrependResult::JoinError => false,\n    }\n}\n\n/// Format PATH with the given directory prepended (simple version).\n///\n/// This is a simpler version that always prepends without deduplication.\n/// Use this for backward compatibility with `format_path_env`.\n///\n/// # Arguments\n/// * `bin_prefix` - The directory to prepend to PATH\n///\n/// # Returns\n/// The new PATH value as a String\npub fn format_path_prepended(bin_prefix: impl AsRef<Path>) -> String {\n    let mut paths = env::split_paths(&env::var_os(\"PATH\").unwrap_or_default()).collect::<Vec<_>>();\n    paths.insert(0, bin_prefix.as_ref().to_path_buf());\n    env::join_paths(paths).unwrap().to_string_lossy().to_string()\n}\n\n#[cfg(test)]\nmod tests {\n    use std::path::PathBuf;\n\n    use super::*;\n\n    #[test]\n    fn test_prepend_options_default() {\n        let options = PrependOptions::default();\n        assert!(!options.dedupe_anywhere);\n    }\n\n    #[test]\n    fn test_format_path_prepended() {\n        let result = format_path_prepended(\"/test/bin\");\n        assert!(result.starts_with(\"/test/bin\"));\n    }\n\n    #[test]\n    fn test_format_path_with_prepend_dedupe_first() {\n        // With dedupe_anywhere = false, should check first element only\n        let options = PrependOptions { dedupe_anywhere: false };\n        let result = format_path_with_prepend(PathBuf::from(\"/new/path\"), options);\n        assert!(matches!(result, PrependResult::Prepended(_)));\n    }\n\n    #[test]\n    fn test_format_path_with_prepend_dedupe_anywhere() {\n        let options = PrependOptions { dedupe_anywhere: true };\n        let result = format_path_with_prepend(PathBuf::from(\"/new/path\"), options);\n        assert!(matches!(result, PrependResult::Prepended(_)));\n    }\n\n    #[test]\n    #[ignore]\n    fn test_format_path_prepended_always_prepends() {\n        // Even if the directory exists somewhere in PATH, it should be prepended\n        let test_dir = \"/test/node/bin\";\n\n        // Set PATH to include test_dir in the middle\n        // SAFETY: This test runs in isolation\n        unsafe {\n            std::env::set_var(\"PATH\", format!(\"/other/bin:{}:/another/bin\", test_dir));\n        }\n\n        let result = format_path_prepended(test_dir);\n\n        // Should start with test_dir regardless of existing PATH entries\n        assert!(\n            result.starts_with(test_dir),\n            \"Directory should always be first in PATH, got: {}\",\n            result\n        );\n\n        // Restore PATH\n        unsafe {\n            std::env::remove_var(\"PATH\");\n        }\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/src/string_similarity.rs",
    "content": "//! String similarity helpers shared by CLI crates.\n\n/// Compute Levenshtein distance between two strings.\n#[must_use]\npub fn levenshtein_distance(left: &str, right: &str) -> usize {\n    let left_chars: Vec<char> = left.chars().collect();\n    let right_chars: Vec<char> = right.chars().collect();\n\n    let mut prev: Vec<usize> = (0..=right_chars.len()).collect();\n    let mut curr = vec![0; right_chars.len() + 1];\n\n    for (i, left_char) in left_chars.iter().enumerate() {\n        curr[0] = i + 1;\n        for (j, right_char) in right_chars.iter().enumerate() {\n            let cost = usize::from(left_char != right_char);\n            let deletion = prev[j + 1] + 1;\n            let insertion = curr[j] + 1;\n            let substitution = prev[j] + cost;\n            curr[j + 1] = deletion.min(insertion).min(substitution);\n        }\n        std::mem::swap(&mut prev, &mut curr);\n    }\n\n    prev[right_chars.len()]\n}\n\n/// Pick the best suggestion by Levenshtein distance and then shortest length.\n#[must_use]\npub fn pick_best_suggestion(input: &str, candidates: &[String]) -> Option<String> {\n    candidates\n        .iter()\n        .min_by_key(|candidate| (levenshtein_distance(input, candidate), candidate.len()))\n        .cloned()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::{levenshtein_distance, pick_best_suggestion};\n\n    #[test]\n    fn distance_works_for_simple_inputs() {\n        assert_eq!(levenshtein_distance(\"fnt\", \"fmt\"), 1);\n        assert_eq!(levenshtein_distance(\"fnt\", \"lint\"), 2);\n    }\n\n    #[test]\n    fn pick_best_prefers_closest_match() {\n        let candidates = vec![\"lint\".to_string(), \"fmt\".to_string()];\n        assert_eq!(pick_best_suggestion(\"fnt\", &candidates), Some(\"fmt\".to_string()));\n    }\n}\n"
  },
  {
    "path": "crates/vite_shared/src/tracing.rs",
    "content": "//! Tracing initialization for vite-plus\n\nuse std::sync::OnceLock;\n\nuse tracing_subscriber::{\n    filter::{LevelFilter, Targets},\n    prelude::*,\n};\n\nuse crate::env_vars;\n\n/// Initialize tracing with VITE_LOG environment variable.\n///\n/// Uses `OnceLock` to ensure tracing is only initialized once,\n/// even if called multiple times.\n///\n/// # Environment Variables\n/// - `VITE_LOG`: Controls log filtering (e.g., \"debug\", \"vite_task=trace\")\npub fn init_tracing() {\n    static TRACING: OnceLock<()> = OnceLock::new();\n    TRACING.get_or_init(|| {\n        tracing_subscriber::registry()\n            .with(\n                std::env::var(env_vars::VITE_LOG)\n                    .map_or_else(\n                        |_| Targets::new(),\n                        |env_var| {\n                            use std::str::FromStr;\n                            Targets::from_str(&env_var).unwrap_or_default()\n                        },\n                    )\n                    // disable brush-parser tracing\n                    .with_targets([(\"tokenize\", LevelFilter::OFF), (\"parse\", LevelFilter::OFF)]),\n            )\n            .with(tracing_subscriber::fmt::layer())\n            .init();\n    });\n}\n"
  },
  {
    "path": "crates/vite_static_config/Cargo.toml",
    "content": "[package]\nname = \"vite_static_config\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nhomepage.workspace = true\nlicense.workspace = true\nrepository.workspace = true\n\n[dependencies]\noxc_allocator = { workspace = true }\noxc_ast = { workspace = true }\noxc_parser = { workspace = true }\noxc_span = { workspace = true }\nrustc-hash = { workspace = true }\nserde_json = { workspace = true }\nvite_path = { workspace = true }\n\n[dev-dependencies]\ntempfile = { workspace = true }\n\n[lints]\nworkspace = true\n\n[lib]\ndoctest = false\n"
  },
  {
    "path": "crates/vite_static_config/README.md",
    "content": "# vite_static_config\n\nStatically extracts configuration from `vite.config.*` files without executing JavaScript.\n\n## What it does\n\nParses vite config files using [oxc_parser](https://crates.io/crates/oxc_parser) and extracts\ntop-level fields whose values are pure JSON literals. This allows reading config like `run`\nwithout needing a Node.js runtime (NAPI).\n\n## Supported patterns\n\n**ESM:**\n\n```js\nexport default { run: { tasks: { build: { command: \"echo build\" } } } }\nexport default defineConfig({ run: { cacheScripts: true } })\n```\n\n**CJS:**\n\n```js\nmodule.exports = { run: { tasks: { build: { command: 'echo build' } } } };\nmodule.exports = defineConfig({ run: { cacheScripts: true } });\n```\n\n## Config file resolution\n\nSearches for config files in the same order as Vite's\n[`DEFAULT_CONFIG_FILES`](https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105):\n\n1. `vite.config.js`\n2. `vite.config.mjs`\n3. `vite.config.ts`\n4. `vite.config.cjs`\n5. `vite.config.mts`\n6. `vite.config.cts`\n\n## Return type\n\n`resolve_static_config` returns `Option<FxHashMap<Box<str>, FieldValue>>`:\n\n- **`None`** — config is not statically analyzable (no config file, parse error, no\n  `export default`/`module.exports`, or the exported value is not an object literal).\n  Caller should fall back to runtime evaluation (e.g. NAPI).\n- **`Some(map)`** — config object was successfully located:\n  - `FieldValue::Json(value)` — field value extracted as pure JSON\n  - `FieldValue::NonStatic` — field exists but contains non-JSON expressions\n    (function calls, variables, template literals with interpolation, etc.)\n  - Key absent — field does not exist in the config object\n\n## Limitations\n\n- Only extracts values that are pure JSON literals (strings, numbers, booleans, null,\n  arrays, and objects composed of these)\n- Fields with dynamic values (function calls, variable references, spread operators,\n  computed properties, template literals with expressions) are reported as `NonStatic`\n- Does not follow imports or evaluate expressions\n"
  },
  {
    "path": "crates/vite_static_config/src/lib.rs",
    "content": "//! Static config extraction from vite.config.* files.\n//!\n//! Parses vite config files statically (without executing JavaScript) to extract\n//! top-level fields whose values are pure JSON literals. This allows reading\n//! config like `run` without needing a Node.js runtime.\n\nuse oxc_allocator::Allocator;\nuse oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement};\nuse oxc_parser::Parser;\nuse oxc_span::SourceType;\nuse rustc_hash::FxHashMap;\nuse vite_path::AbsolutePath;\n\n/// The result of statically analyzing a single config field's value.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum FieldValue {\n    /// The field value was successfully extracted as a JSON literal.\n    Json(serde_json::Value),\n    /// The field exists but its value is not a pure JSON literal (e.g. contains\n    /// function calls, variables, template literals with expressions, etc.)\n    NonStatic,\n}\n\n/// Internal representation of extracted object fields.\n///\n/// Two variants model the closed-world vs open-world assumption:\n///\n/// - [`FieldMapInner::Closed`] — the object had no spreads or computed-key properties.\n///   The map is exhaustive: every field is accounted for, absent keys do not exist.\n///\n/// - [`FieldMapInner::Open`] — the object had at least one spread or computed-key\n///   property. The map contains only [`serde_json::Value`] entries for keys\n///   explicitly declared **after** the last such entry. Absent keys may exist via\n///   the spread and are treated as [`FieldValue::NonStatic`] by [`FieldMap::get`].\nenum FieldMapInner {\n    Closed(FxHashMap<Box<str>, FieldValue>),\n    Open(FxHashMap<Box<str>, serde_json::Value>),\n}\n\n/// Extracted fields from a vite config object.\npub struct FieldMap(FieldMapInner);\n\nimpl FieldMap {\n    /// Returns an open empty map — used when the config is not analyzable.\n    /// `get()` returns `Some(NonStatic)` for any key, triggering NAPI fallback.\n    fn unanalyzable() -> Self {\n        Self(FieldMapInner::Open(FxHashMap::default()))\n    }\n\n    /// Returns a closed empty map — used when no config file exists.\n    /// `get()` returns `None` for any key (field definitively absent).\n    fn no_config() -> Self {\n        Self(FieldMapInner::Closed(FxHashMap::default()))\n    }\n\n    /// Look up a field by name.\n    ///\n    /// - [`Closed`](FieldMapInner::Closed): returns the stored value, or `None`\n    ///   if the field is definitively absent.\n    /// - [`Open`](FieldMapInner::Open): returns the stored `Json` value if\n    ///   explicitly declared after the last spread/computed key, or\n    ///   `Some(NonStatic)` for any other key (it may exist in the spread).\n    #[must_use]\n    pub fn get(&self, key: &str) -> Option<FieldValue> {\n        match &self.0 {\n            FieldMapInner::Closed(map) => map.get(key).cloned(),\n            FieldMapInner::Open(map) => {\n                Some(map.get(key).map_or(FieldValue::NonStatic, |v| FieldValue::Json(v.clone())))\n            }\n        }\n    }\n}\n\n/// Config file names to try, in priority order.\n/// This matches Vite's `DEFAULT_CONFIG_FILES`:\n/// <https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105>\n///\n/// Vite resolves config files by iterating this list and checking `fs.existsSync` — no\n/// module resolution involved, so `oxc_resolver` is not needed here:\n/// <https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/config.ts#L2231-L2237>\nconst CONFIG_FILE_NAMES: &[&str] = &[\n    \"vite.config.js\",\n    \"vite.config.mjs\",\n    \"vite.config.ts\",\n    \"vite.config.cjs\",\n    \"vite.config.mts\",\n    \"vite.config.cts\",\n];\n\n/// Resolve the vite config file path in the given directory.\n///\n/// Tries each config file name in priority order and returns the first one that exists.\nfn resolve_config_path(dir: &AbsolutePath) -> Option<vite_path::AbsolutePathBuf> {\n    for name in CONFIG_FILE_NAMES {\n        let path = dir.join(name);\n        if path.as_path().exists() {\n            return Some(path);\n        }\n    }\n    None\n}\n\n/// Resolve and parse a vite config file from the given directory.\n///\n/// Returns a [`FieldMap`]; use [`FieldMap::get`] to query individual fields.\n#[must_use]\npub fn resolve_static_config(dir: &AbsolutePath) -> FieldMap {\n    let Some(config_path) = resolve_config_path(dir) else {\n        // No config file found — closed empty map; get() returns None for any key.\n        return FieldMap::no_config();\n    };\n    let Ok(source) = std::fs::read_to_string(&config_path) else {\n        return FieldMap::unanalyzable();\n    };\n\n    let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(\"\");\n\n    if extension == \"json\" {\n        return parse_json_config(&source);\n    }\n\n    parse_js_ts_config(&source, extension)\n}\n\n/// Parse a JSON config file into a map of field names to values.\n/// All fields in a valid JSON object are fully static.\nfn parse_json_config(source: &str) -> FieldMap {\n    let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(source) else {\n        return FieldMap::unanalyzable();\n    };\n    let mut map = FxHashMap::with_capacity_and_hasher(obj.len(), Default::default());\n    for (k, v) in &obj {\n        map.insert(Box::from(k.as_str()), FieldValue::Json(v.clone()));\n    }\n    FieldMap(FieldMapInner::Closed(map))\n}\n\n/// Parse a JS/TS config file, extracting the default export object's fields.\nfn parse_js_ts_config(source: &str, extension: &str) -> FieldMap {\n    let allocator = Allocator::default();\n    let source_type = match extension {\n        \"ts\" | \"mts\" | \"cts\" => SourceType::ts(),\n        _ => SourceType::mjs(),\n    };\n\n    let parser = Parser::new(&allocator, source, source_type);\n    let result = parser.parse();\n\n    if result.panicked || !result.errors.is_empty() {\n        return FieldMap::unanalyzable();\n    }\n\n    extract_config_fields(&result.program)\n}\n\n/// Find the config object in a parsed program and extract its fields.\n///\n/// Searches for the config value in the following patterns (in order):\n/// 1. `export default defineConfig({ ... })`\n/// 2. `export default { ... }`\n/// 3. `module.exports = defineConfig({ ... })`\n/// 4. `module.exports = { ... }`\nfn extract_config_fields(program: &Program<'_>) -> FieldMap {\n    for stmt in &program.body {\n        // ESM: export default ...\n        if let Statement::ExportDefaultDeclaration(decl) = stmt {\n            if let Some(expr) = decl.declaration.as_expression() {\n                return extract_config_from_expr(expr);\n            }\n            // export default class/function — not analyzable\n            return FieldMap::unanalyzable();\n        }\n\n        // CJS: module.exports = ...\n        if let Statement::ExpressionStatement(expr_stmt) = stmt\n            && let Expression::AssignmentExpression(assign) = &expr_stmt.expression\n            && assign.left.as_member_expression().is_some_and(|m| {\n                m.object().is_specific_id(\"module\") && m.static_property_name() == Some(\"exports\")\n            })\n        {\n            return extract_config_from_expr(&assign.right);\n        }\n    }\n\n    FieldMap::unanalyzable()\n}\n\n/// Extract the config object from an expression that is either:\n/// - `defineConfig({ ... })` → extract the object argument\n/// - `defineConfig(() => ({ ... }))` → extract from arrow function expression body\n/// - `defineConfig(() => { return { ... }; })` → extract from return statement\n/// - `defineConfig(function() { return { ... }; })` → extract from return statement\n/// - `{ ... }` → extract directly\n/// - anything else → not analyzable\nfn extract_config_from_expr(expr: &Expression<'_>) -> FieldMap {\n    let expr = expr.without_parentheses();\n    match expr {\n        Expression::CallExpression(call) => {\n            if !call.callee.is_specific_id(\"defineConfig\") {\n                return FieldMap::unanalyzable();\n            }\n            let Some(first_arg) = call.arguments.first() else {\n                return FieldMap::unanalyzable();\n            };\n            let Some(first_arg_expr) = first_arg.as_expression() else {\n                return FieldMap::unanalyzable();\n            };\n            match first_arg_expr {\n                Expression::ObjectExpression(obj) => extract_object_fields(obj),\n                Expression::ArrowFunctionExpression(arrow) => {\n                    extract_config_from_function_body(&arrow.body)\n                }\n                Expression::FunctionExpression(func) => {\n                    let Some(body) = func.body.as_ref() else {\n                        return FieldMap::unanalyzable();\n                    };\n                    extract_config_from_function_body(body)\n                }\n                _ => FieldMap::unanalyzable(),\n            }\n        }\n        Expression::ObjectExpression(obj) => extract_object_fields(obj),\n        _ => FieldMap::unanalyzable(),\n    }\n}\n\n/// Extract the config object from the body of a function passed to `defineConfig`.\n///\n/// Handles two patterns:\n/// - Concise arrow body: `() => ({ ... })` — body has a single `ExpressionStatement`\n/// - Block body with exactly one return: `() => { ... return { ... }; }`\n///\n/// Returns `FieldMap::unanalyzable()` if the body contains multiple `return` statements\n/// (at any nesting depth), since the returned config would depend on runtime control flow.\nfn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> FieldMap {\n    // Reject functions with multiple returns — the config depends on control flow.\n    if count_returns_in_stmts(&body.statements) > 1 {\n        return FieldMap::unanalyzable();\n    }\n\n    for stmt in &body.statements {\n        match stmt {\n            Statement::ReturnStatement(ret) => {\n                let Some(arg) = ret.argument.as_ref() else {\n                    return FieldMap::unanalyzable();\n                };\n                if let Expression::ObjectExpression(obj) = arg.without_parentheses() {\n                    return extract_object_fields(obj);\n                }\n                return FieldMap::unanalyzable();\n            }\n            Statement::ExpressionStatement(expr_stmt) => {\n                // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement\n                if let Expression::ObjectExpression(obj) =\n                    expr_stmt.expression.without_parentheses()\n                {\n                    return extract_object_fields(obj);\n                }\n            }\n            _ => {}\n        }\n    }\n    FieldMap::unanalyzable()\n}\n\n/// Count `return` statements recursively in a slice of statements.\n/// Does not descend into nested function/arrow expressions (they have their own returns).\nfn count_returns_in_stmts(stmts: &[Statement<'_>]) -> usize {\n    let mut count = 0;\n    for stmt in stmts {\n        count += count_returns_in_stmt(stmt);\n    }\n    count\n}\n\nfn count_returns_in_stmt(stmt: &Statement<'_>) -> usize {\n    match stmt {\n        Statement::ReturnStatement(_) => 1,\n        Statement::BlockStatement(block) => count_returns_in_stmts(&block.body),\n        Statement::IfStatement(if_stmt) => {\n            let mut n = count_returns_in_stmt(&if_stmt.consequent);\n            if let Some(alt) = &if_stmt.alternate {\n                n += count_returns_in_stmt(alt);\n            }\n            n\n        }\n        Statement::SwitchStatement(switch) => {\n            let mut n = 0;\n            for case in &switch.cases {\n                n += count_returns_in_stmts(&case.consequent);\n            }\n            n\n        }\n        Statement::TryStatement(try_stmt) => {\n            let mut n = count_returns_in_stmts(&try_stmt.block.body);\n            if let Some(handler) = &try_stmt.handler {\n                n += count_returns_in_stmts(&handler.body.body);\n            }\n            if let Some(finalizer) = &try_stmt.finalizer {\n                n += count_returns_in_stmts(&finalizer.body);\n            }\n            n\n        }\n        Statement::ForStatement(s) => count_returns_in_stmt(&s.body),\n        Statement::ForInStatement(s) => count_returns_in_stmt(&s.body),\n        Statement::ForOfStatement(s) => count_returns_in_stmt(&s.body),\n        Statement::WhileStatement(s) => count_returns_in_stmt(&s.body),\n        Statement::DoWhileStatement(s) => count_returns_in_stmt(&s.body),\n        Statement::LabeledStatement(s) => count_returns_in_stmt(&s.body),\n        Statement::WithStatement(s) => count_returns_in_stmt(&s.body),\n        _ => 0,\n    }\n}\n\n/// Extract fields from an object expression into a [`FieldMap`].\n///\n/// Objects with no spreads or computed-key properties produce a [`FieldMapInner::Closed`]\n/// map — absent keys are definitively absent. Objects with at least one such property\n/// produce a [`FieldMapInner::Open`] map — absent keys may exist via the spread and\n/// [`FieldMap::get`] returns [`FieldValue::NonStatic`] for them.\n///\n/// When a spread or computed key is encountered the map transitions to\n/// [`FieldMapInner::Open`] (discarding pre-spread entries): they would all be\n/// [`FieldValue::NonStatic`] anyway, and `Open` already returns `NonStatic` for every absent key.\n///\n/// Fields declared after the last spread/computed key are still extractable:\n///\n/// ```js\n/// { a: 1, ...x, b: 2 }  // Open{ b: Json(2) };  get(\"a\") = NonStatic, get(\"b\") = Json(2)\n/// { a: 1, [k]: 2, b: 3 } // Open{ b: Json(3) };  get(\"a\") = NonStatic, get(\"b\") = Json(3)\n/// { a: 1, b: 2 }         // Closed{ a: Json(1), b: Json(2) }; get(\"c\") = None\n/// ```\nfn extract_object_fields(obj: &oxc_ast::ast::ObjectExpression<'_>) -> FieldMap {\n    let mut inner = FieldMapInner::Closed(FxHashMap::default());\n\n    for prop in &obj.properties {\n        if prop.is_spread() {\n            inner = FieldMapInner::Open(FxHashMap::default());\n            continue;\n        }\n        let ObjectPropertyKind::ObjectProperty(prop) = prop else { continue };\n        let Some(key) = prop.key.static_name() else {\n            inner = FieldMapInner::Open(FxHashMap::default());\n            continue;\n        };\n\n        match &mut inner {\n            FieldMapInner::Closed(map) => {\n                let value =\n                    expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json);\n                map.insert(Box::from(key.as_ref()), value);\n            }\n            FieldMapInner::Open(map) => {\n                // Only Json values are meaningful in Open — NonStatic is already implied\n                // for any absent key, so there's no need to record it explicitly.\n                if let Some(json) = expr_to_json(&prop.value) {\n                    map.insert(Box::from(key.as_ref()), json);\n                }\n            }\n        }\n    }\n\n    FieldMap(inner)\n}\n\n/// Convert an f64 to a JSON value following `JSON.stringify` semantics.\n/// `NaN`, `Infinity`, `-Infinity` become `null`; `-0` becomes `0`.\nfn f64_to_json_number(value: f64) -> serde_json::Value {\n    // fract() == 0.0 ensures the value is a whole number, so the cast is lossless.\n    #[expect(clippy::cast_possible_truncation)]\n    if value.fract() == 0.0\n        && let Ok(i) = i64::try_from(value as i128)\n    {\n        serde_json::Value::from(i)\n    } else {\n        // From<f64> for Value: finite → Number, NaN/Infinity → Null\n        serde_json::Value::from(value)\n    }\n}\n\n/// Try to convert an AST expression to a JSON value.\n///\n/// Returns `None` if the expression contains non-JSON-literal nodes\n/// (function calls, identifiers, template literals, etc.)\nfn expr_to_json(expr: &Expression<'_>) -> Option<serde_json::Value> {\n    let expr = expr.without_parentheses();\n    match expr {\n        Expression::NullLiteral(_) => Some(serde_json::Value::Null),\n\n        Expression::BooleanLiteral(lit) => Some(serde_json::Value::Bool(lit.value)),\n\n        Expression::NumericLiteral(lit) => Some(f64_to_json_number(lit.value)),\n\n        Expression::StringLiteral(lit) => Some(serde_json::Value::String(lit.value.to_string())),\n\n        Expression::TemplateLiteral(lit) => {\n            let quasi = lit.single_quasi()?;\n            Some(serde_json::Value::String(quasi.to_string()))\n        }\n\n        Expression::UnaryExpression(unary) => {\n            // Handle negative numbers: -42\n            if unary.operator == oxc_ast::ast::UnaryOperator::UnaryNegation\n                && let Expression::NumericLiteral(lit) = &unary.argument\n            {\n                return Some(f64_to_json_number(-lit.value));\n            }\n            None\n        }\n\n        Expression::ArrayExpression(arr) => {\n            let mut values = Vec::with_capacity(arr.elements.len());\n            for elem in &arr.elements {\n                if elem.is_elision() {\n                    values.push(serde_json::Value::Null);\n                } else if elem.is_spread() {\n                    return None;\n                } else {\n                    let elem_expr = elem.as_expression()?;\n                    values.push(expr_to_json(elem_expr)?);\n                }\n            }\n            Some(serde_json::Value::Array(values))\n        }\n\n        Expression::ObjectExpression(obj) => {\n            let mut map = serde_json::Map::new();\n            for prop in &obj.properties {\n                if prop.is_spread() {\n                    return None;\n                }\n                let ObjectPropertyKind::ObjectProperty(prop) = prop else {\n                    continue;\n                };\n                let key = prop.key.static_name()?;\n                let value = expr_to_json(&prop.value)?;\n                map.insert(key.into_owned(), value);\n            }\n            Some(serde_json::Value::Object(map))\n        }\n\n        _ => None,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use tempfile::TempDir;\n\n    use super::*;\n\n    /// Helper: parse JS/TS source and return the field map.\n    fn parse(source: &str) -> FieldMap {\n        parse_js_ts_config(source, \"ts\")\n    }\n\n    /// Shorthand for asserting a field extracted as JSON.\n    fn assert_json(map: &FieldMap, key: &str, expected: serde_json::Value) {\n        assert_eq!(map.get(key), Some(FieldValue::Json(expected)));\n    }\n\n    /// Shorthand for asserting a field is `NonStatic`.\n    fn assert_non_static(map: &FieldMap, key: &str) {\n        assert_eq!(\n            map.get(key),\n            Some(FieldValue::NonStatic),\n            \"expected field {key:?} to be NonStatic\"\n        );\n    }\n\n    // ── Config file resolution ──────────────────────────────────────────\n\n    #[test]\n    fn resolves_ts_config() {\n        let dir = TempDir::new().unwrap();\n        let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();\n        std::fs::write(dir.path().join(\"vite.config.ts\"), \"export default { run: {} }\").unwrap();\n        let result = resolve_static_config(&dir_path);\n        assert!(result.get(\"run\").is_some());\n    }\n\n    #[test]\n    fn resolves_js_config() {\n        let dir = TempDir::new().unwrap();\n        let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();\n        std::fs::write(dir.path().join(\"vite.config.js\"), \"export default { run: {} }\").unwrap();\n        let result = resolve_static_config(&dir_path);\n        assert!(result.get(\"run\").is_some());\n    }\n\n    #[test]\n    fn resolves_mts_config() {\n        let dir = TempDir::new().unwrap();\n        let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();\n        std::fs::write(dir.path().join(\"vite.config.mts\"), \"export default { run: {} }\").unwrap();\n        let result = resolve_static_config(&dir_path);\n        assert!(result.get(\"run\").is_some());\n    }\n\n    #[test]\n    fn js_takes_priority_over_ts() {\n        let dir = TempDir::new().unwrap();\n        let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();\n        std::fs::write(dir.path().join(\"vite.config.ts\"), \"export default { fromTs: true }\")\n            .unwrap();\n        std::fs::write(dir.path().join(\"vite.config.js\"), \"export default { fromJs: true }\")\n            .unwrap();\n        let result = resolve_static_config(&dir_path);\n        assert!(result.get(\"fromJs\").is_some());\n        assert!(result.get(\"fromTs\").is_none());\n    }\n\n    #[test]\n    fn returns_empty_map_for_no_config() {\n        let dir = TempDir::new().unwrap();\n        let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();\n        let result = resolve_static_config(&dir_path);\n        assert!(result.get(\"run\").is_none());\n    }\n\n    // ── JSON config parsing ─────────────────────────────────────────────\n\n    #[test]\n    fn parses_json_config() {\n        let dir = TempDir::new().unwrap();\n        let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap();\n        std::fs::write(\n            dir.path().join(\"vite.config.ts\"),\n            r#\"export default { run: { tasks: { build: { command: \"echo hello\" } } } }\"#,\n        )\n        .unwrap();\n        let result = resolve_static_config(&dir_path);\n        assert_json(\n            &result,\n            \"run\",\n            serde_json::json!({ \"tasks\": { \"build\": { \"command\": \"echo hello\" } } }),\n        );\n    }\n\n    // ── export default { ... } ──────────────────────────────────────────\n\n    #[test]\n    fn plain_export_default_object() {\n        let result = parse(\"export default { foo: 'bar', num: 42 }\");\n        assert_json(&result, \"foo\", serde_json::json!(\"bar\"));\n        assert_json(&result, \"num\", serde_json::json!(42));\n    }\n\n    #[test]\n    fn export_default_empty_object() {\n        let result = parse(\"export default {}\");\n        assert!(result.get(\"run\").is_none());\n    }\n\n    // ── export default defineConfig({ ... }) ────────────────────────────\n\n    #[test]\n    fn define_config_call() {\n        let result = parse(\n            r\"\n            import { defineConfig } from 'vite-plus';\n            export default defineConfig({\n                run: { cacheScripts: true },\n                lint: { plugins: ['a'] },\n            });\n            \",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n        assert_json(&result, \"lint\", serde_json::json!({ \"plugins\": [\"a\"] }));\n    }\n\n    // ── module.exports = { ... } ───────────────────────────────────────\n\n    #[test]\n    fn module_exports_object() {\n        let result = parse_js_ts_config(\"module.exports = { run: { cache: true } }\", \"cjs\");\n        assert_json(&result, \"run\", serde_json::json!({ \"cache\": true }));\n    }\n\n    #[test]\n    fn module_exports_define_config() {\n        let result = parse_js_ts_config(\n            r\"\n            const { defineConfig } = require('vite-plus');\n            module.exports = defineConfig({\n                run: { cacheScripts: true },\n            });\n            \",\n            \"cjs\",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n    }\n\n    #[test]\n    fn module_exports_non_object() {\n        assert_non_static(&parse_js_ts_config(\"module.exports = 42;\", \"cjs\"), \"run\");\n    }\n\n    #[test]\n    fn module_exports_unknown_call() {\n        assert_non_static(&parse_js_ts_config(\"module.exports = otherFn({ a: 1 });\", \"cjs\"), \"run\");\n    }\n\n    // ── Primitive values ────────────────────────────────────────────────\n\n    #[test]\n    fn string_values() {\n        let result = parse(r#\"export default { a: \"double\", b: 'single' }\"#);\n        assert_json(&result, \"a\", serde_json::json!(\"double\"));\n        assert_json(&result, \"b\", serde_json::json!(\"single\"));\n    }\n\n    #[test]\n    fn numeric_values() {\n        let result = parse(\"export default { a: 42, b: 1.5, c: 0, d: -1 }\");\n        assert_json(&result, \"a\", serde_json::json!(42));\n        assert_json(&result, \"b\", serde_json::json!(1.5));\n        assert_json(&result, \"c\", serde_json::json!(0));\n        assert_json(&result, \"d\", serde_json::json!(-1));\n    }\n\n    #[test]\n    fn numeric_overflow_to_infinity_is_null() {\n        // 1e999 overflows f64 to Infinity; JSON.stringify(Infinity) === \"null\"\n        let result = parse(\"export default { a: 1e999, b: -1e999 }\");\n        assert_json(&result, \"a\", serde_json::Value::Null);\n        assert_json(&result, \"b\", serde_json::Value::Null);\n    }\n\n    #[test]\n    fn negative_zero_is_zero() {\n        // JSON.stringify(-0) === \"0\"\n        let result = parse(\"export default { a: -0 }\");\n        assert_json(&result, \"a\", serde_json::json!(0));\n    }\n\n    #[test]\n    fn boolean_values() {\n        let result = parse(\"export default { a: true, b: false }\");\n        assert_json(&result, \"a\", serde_json::json!(true));\n        assert_json(&result, \"b\", serde_json::json!(false));\n    }\n\n    #[test]\n    fn null_value() {\n        let result = parse(\"export default { a: null }\");\n        assert_json(&result, \"a\", serde_json::Value::Null);\n    }\n\n    // ── Arrays ──────────────────────────────────────────────────────────\n\n    #[test]\n    fn array_of_strings() {\n        let result = parse(\"export default { items: ['a', 'b', 'c'] }\");\n        assert_json(&result, \"items\", serde_json::json!([\"a\", \"b\", \"c\"]));\n    }\n\n    #[test]\n    fn nested_arrays() {\n        let result = parse(\"export default { matrix: [[1, 2], [3, 4]] }\");\n        assert_json(&result, \"matrix\", serde_json::json!([[1, 2], [3, 4]]));\n    }\n\n    #[test]\n    fn empty_array() {\n        let result = parse(\"export default { items: [] }\");\n        assert_json(&result, \"items\", serde_json::json!([]));\n    }\n\n    // ── Nested objects ──────────────────────────────────────────────────\n\n    #[test]\n    fn nested_object() {\n        let result = parse(\n            r#\"export default {\n                run: {\n                    tasks: {\n                        build: {\n                            command: \"echo build\",\n                            dependsOn: [\"lint\"],\n                            cache: true,\n                        }\n                    }\n                }\n            }\"#,\n        );\n        assert_json(\n            &result,\n            \"run\",\n            serde_json::json!({\n                \"tasks\": {\n                    \"build\": {\n                        \"command\": \"echo build\",\n                        \"dependsOn\": [\"lint\"],\n                        \"cache\": true,\n                    }\n                }\n            }),\n        );\n    }\n\n    // ── NonStatic fields ────────────────────────────────────────────────\n\n    #[test]\n    fn non_static_function_call_values() {\n        let result = parse(\n            r\"export default {\n                run: { cacheScripts: true },\n                plugins: [myPlugin()],\n            }\",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n        assert_non_static(&result, \"plugins\");\n    }\n\n    #[test]\n    fn non_static_identifier_values() {\n        let result = parse(\n            r\"\n            const myVar = 'hello';\n            export default { a: myVar, b: 42 }\n            \",\n        );\n        assert_non_static(&result, \"a\");\n        assert_json(&result, \"b\", serde_json::json!(42));\n    }\n\n    #[test]\n    fn non_static_template_literal_with_expressions() {\n        let result = parse(\n            r\"\n            const x = 'world';\n            export default { a: `hello ${x}`, b: 'plain' }\n            \",\n        );\n        assert_non_static(&result, \"a\");\n        assert_json(&result, \"b\", serde_json::json!(\"plain\"));\n    }\n\n    #[test]\n    fn keeps_pure_template_literal() {\n        let result = parse(\"export default { a: `hello` }\");\n        assert_json(&result, \"a\", serde_json::json!(\"hello\"));\n    }\n\n    #[test]\n    fn non_static_spread_in_object_value() {\n        let result = parse(\n            r\"\n            const base = { x: 1 };\n            export default { a: { ...base, y: 2 }, b: 'ok' }\n            \",\n        );\n        assert_non_static(&result, \"a\");\n        assert_json(&result, \"b\", serde_json::json!(\"ok\"));\n    }\n\n    #[test]\n    fn spread_unknown_keys_not_in_map() {\n        // The spread produces an Open map. Keys from inside the spread (like \"x\") are\n        // not explicitly declared, so get(\"x\") returns NonStatic (may exist via spread).\n        // Fields declared after the spread are still extracted as Json.\n        let result = parse(\n            r\"\n            const base = { x: 1 };\n            export default { ...base, b: 'ok' }\n            \",\n        );\n        assert_non_static(&result, \"x\");\n        assert_json(&result, \"b\", serde_json::json!(\"ok\"));\n    }\n\n    #[test]\n    fn spread_invalidates_previous_fields() {\n        // Pre-spread fields are discarded from the Open map (they're all NonStatic via\n        // the open-world fallback). Fields after the spread are still extracted.\n        let result = parse(\n            r\"\n            const base = { x: 1 };\n            export default { a: 1, run: { cacheScripts: true }, ...base, b: 'ok' }\n            \",\n        );\n        assert_non_static(&result, \"a\");\n        assert_non_static(&result, \"run\");\n        assert_non_static(&result, \"x\");\n        assert_json(&result, \"b\", serde_json::json!(\"ok\"));\n    }\n\n    #[test]\n    fn spread_only() {\n        // A bare spread with no explicit keys after it: every key is NonStatic.\n        let result = parse(\n            r\"\n            const base = { run: { cacheScripts: true } };\n            export default { ...base }\n            \",\n        );\n        assert_non_static(&result, \"run\");\n    }\n\n    #[test]\n    fn spread_then_explicit_run() {\n        // run is explicitly declared after the spread and is still extractable as Json.\n        let result = parse(\n            r\"\n            const base = { plugins: [] };\n            export default { ...base, run: { cacheScripts: true } }\n            \",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n    }\n\n    #[test]\n    fn no_spread_absent_is_none() {\n        // No spreads: Closed map — absent keys are definitively absent.\n        let result = parse(r\"export default { plugins: [] }\");\n        assert!(result.get(\"run\").is_none());\n    }\n\n    #[test]\n    fn computed_key_unknown_not_in_map() {\n        // The computed key produces an Open map. Keys not declared after it return NonStatic.\n        let result = parse(\n            r\"\n            const key = 'dynamic';\n            export default { [key]: 'value', plain: 'ok' }\n            \",\n        );\n        assert_non_static(&result, \"dynamic\");\n        assert_json(&result, \"plain\", serde_json::json!(\"ok\"));\n    }\n\n    #[test]\n    fn computed_key_invalidates_previous_fields() {\n        // A computed key produces Open — pre-computed fields become NonStatic via the\n        // open-world fallback. Fields declared after are extracted normally.\n        let result = parse(\n            r\"\n            const key = 'run';\n            export default { a: 1, run: { cacheScripts: true }, [key]: 'override', b: 2 }\n            \",\n        );\n        assert_non_static(&result, \"a\");\n        assert_non_static(&result, \"run\");\n        assert_json(&result, \"b\", serde_json::json!(2));\n    }\n\n    #[test]\n    fn non_static_array_with_spread() {\n        let result = parse(\n            r\"\n            const arr = [1, 2];\n            export default { a: [...arr, 3], b: 'ok' }\n            \",\n        );\n        assert_non_static(&result, \"a\");\n        assert_json(&result, \"b\", serde_json::json!(\"ok\"));\n    }\n\n    // ── Property key types ──────────────────────────────────────────────\n\n    #[test]\n    fn string_literal_keys() {\n        let result = parse(r\"export default { 'string-key': 42 }\");\n        assert_json(&result, \"string-key\", serde_json::json!(42));\n    }\n\n    // ── Real-world patterns ─────────────────────────────────────────────\n\n    #[test]\n    fn real_world_run_config() {\n        let result = parse(\n            r#\"\n            export default {\n                run: {\n                    tasks: {\n                        build: {\n                            command: \"echo 'build from vite.config.ts'\",\n                            dependsOn: [],\n                        },\n                    },\n                },\n            };\n            \"#,\n        );\n        assert_json(\n            &result,\n            \"run\",\n            serde_json::json!({\n                \"tasks\": {\n                    \"build\": {\n                        \"command\": \"echo 'build from vite.config.ts'\",\n                        \"dependsOn\": [],\n                    }\n                }\n            }),\n        );\n    }\n\n    #[test]\n    fn real_world_with_non_json_fields() {\n        let result = parse(\n            r\"\n            import { defineConfig } from 'vite-plus';\n\n            export default defineConfig({\n                lint: {\n                    plugins: ['unicorn', 'typescript'],\n                    rules: {\n                        'no-console': ['error', { allow: ['error'] }],\n                    },\n                },\n                run: {\n                    tasks: {\n                        'build:src': {\n                            command: 'vp run rolldown#build-binding:release',\n                        },\n                    },\n                },\n            });\n            \",\n        );\n        assert_json(\n            &result,\n            \"lint\",\n            serde_json::json!({\n                \"plugins\": [\"unicorn\", \"typescript\"],\n                \"rules\": {\n                    \"no-console\": [\"error\", { \"allow\": [\"error\"] }],\n                },\n            }),\n        );\n        assert_json(\n            &result,\n            \"run\",\n            serde_json::json!({\n                \"tasks\": {\n                    \"build:src\": {\n                        \"command\": \"vp run rolldown#build-binding:release\",\n                    }\n                }\n            }),\n        );\n    }\n\n    #[test]\n    fn skips_non_default_exports() {\n        let result = parse(\n            r\"\n            export const config = { a: 1 };\n            export default { b: 2 };\n            \",\n        );\n        assert!(result.get(\"a\").is_none());\n        assert_json(&result, \"b\", serde_json::json!(2));\n    }\n\n    // ── defineConfig with function argument ────────────────────────────\n\n    #[test]\n    fn define_config_arrow_block_body() {\n        let result = parse(\n            r\"\n            export default defineConfig(({ mode }) => {\n                const env = loadEnv(mode, process.cwd(), '');\n                return {\n                    run: { cacheScripts: true },\n                    plugins: [vue()],\n                };\n            });\n            \",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n        assert_non_static(&result, \"plugins\");\n    }\n\n    #[test]\n    fn define_config_arrow_expression_body() {\n        let result = parse(\n            r\"\n            export default defineConfig(() => ({\n                run: { cacheScripts: true },\n                build: { outDir: 'dist' },\n            }));\n            \",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n        assert_json(&result, \"build\", serde_json::json!({ \"outDir\": \"dist\" }));\n    }\n\n    #[test]\n    fn define_config_function_expression() {\n        let result = parse(\n            r\"\n            export default defineConfig(function() {\n                return {\n                    run: { cacheScripts: true },\n                    plugins: [react()],\n                };\n            });\n            \",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n        assert_non_static(&result, \"plugins\");\n    }\n\n    #[test]\n    fn define_config_arrow_no_return_object() {\n        // Arrow function that doesn't return an object literal\n        assert_non_static(\n            &parse_js_ts_config(\n                r\"\n            export default defineConfig(({ mode }) => {\n                return someFunction();\n            });\n            \",\n                \"ts\",\n            ),\n            \"run\",\n        );\n    }\n\n    #[test]\n    fn define_config_arrow_multiple_returns() {\n        // Multiple top-level returns → not analyzable\n        assert_non_static(\n            &parse_js_ts_config(\n                r\"\n            export default defineConfig(({ mode }) => {\n                if (mode === 'production') {\n                    return { run: { cacheScripts: true } };\n                }\n                return { run: { cacheScripts: false } };\n            });\n            \",\n                \"ts\",\n            ),\n            \"run\",\n        );\n    }\n\n    #[test]\n    fn define_config_arrow_empty_body() {\n        assert_non_static(\n            &parse_js_ts_config(\"export default defineConfig(() => {});\", \"ts\"),\n            \"run\",\n        );\n    }\n\n    // ── Not analyzable cases ─────────────────────────────────────────────\n\n    #[test]\n    fn returns_none_for_no_default_export() {\n        assert_non_static(&parse_js_ts_config(\"export const config = { a: 1 };\", \"ts\"), \"run\");\n    }\n\n    #[test]\n    fn returns_none_for_non_object_default_export() {\n        assert_non_static(&parse_js_ts_config(\"export default 42;\", \"ts\"), \"run\");\n    }\n\n    #[test]\n    fn returns_none_for_unknown_function_call() {\n        assert_non_static(\n            &parse_js_ts_config(\"export default someOtherFn({ a: 1 });\", \"ts\"),\n            \"run\",\n        );\n    }\n\n    #[test]\n    fn handles_trailing_commas() {\n        let result = parse(\n            r\"export default {\n                a: [1, 2, 3,],\n                b: { x: 1, y: 2, },\n            }\",\n        );\n        assert_json(&result, \"a\", serde_json::json!([1, 2, 3]));\n        assert_json(&result, \"b\", serde_json::json!({ \"x\": 1, \"y\": 2 }));\n    }\n\n    #[test]\n    fn task_with_cache_config() {\n        let result = parse(\n            r\"export default {\n                run: {\n                    tasks: {\n                        hello: {\n                            command: 'node hello.mjs',\n                            envs: ['FOO', 'BAR'],\n                            cache: true,\n                        },\n                    },\n                },\n            }\",\n        );\n        assert_json(\n            &result,\n            \"run\",\n            serde_json::json!({\n                \"tasks\": {\n                    \"hello\": {\n                        \"command\": \"node hello.mjs\",\n                        \"envs\": [\"FOO\", \"BAR\"],\n                        \"cache\": true,\n                    }\n                }\n            }),\n        );\n    }\n\n    #[test]\n    fn non_static_method_call_in_nested_value() {\n        let result = parse(\n            r\"export default {\n                run: {\n                    tasks: {\n                        'build:src': {\n                            command: ['cmd1', 'cmd2'].join(' && '),\n                        },\n                    },\n                },\n                lint: { plugins: ['a'] },\n            }\",\n        );\n        // `run` is NonStatic because its nested value contains a method call\n        assert_non_static(&result, \"run\");\n        assert_json(&result, \"lint\", serde_json::json!({ \"plugins\": [\"a\"] }));\n    }\n\n    #[test]\n    fn cache_scripts_only() {\n        let result = parse(\n            r\"export default {\n                run: {\n                    cacheScripts: true,\n                },\n            }\",\n        );\n        assert_json(&result, \"run\", serde_json::json!({ \"cacheScripts\": true }));\n    }\n}\n"
  },
  {
    "path": "crates/vite_trampoline/Cargo.toml",
    "content": "[package]\nname = \"vite_trampoline\"\nversion = \"0.0.0\"\nauthors.workspace = true\nedition.workspace = true\nlicense.workspace = true\npublish = false\ndescription = \"Minimal Windows trampoline exe for vite-plus shims\"\n\n[[bin]]\nname = \"vp-shim\"\npath = \"src/main.rs\"\n\n# No dependencies — the single Win32 FFI call (SetConsoleCtrlHandler) is\n# declared inline to avoid pulling in the heavy `windows`/`windows-core` crates.\n\n# Override workspace lints: this is a standalone minimal binary that intentionally\n# avoids dependencies on vite_shared, vite_path, vite_str, etc. to keep binary\n# size small. It uses std types and macros directly.\n[lints.clippy]\ndisallowed_macros = \"allow\"\ndisallowed_types = \"allow\"\ndisallowed_methods = \"allow\"\n\n# Note: Release profile is defined at workspace root (Cargo.toml).\n# The workspace already sets lto=\"fat\", codegen-units=1, strip=\"symbols\", panic=\"abort\".\n# For even smaller binaries, consider building this crate separately with opt-level=\"z\".\n"
  },
  {
    "path": "crates/vite_trampoline/src/main.rs",
    "content": "//! Minimal Windows trampoline for vite-plus shims.\n//!\n//! This binary is copied and renamed for each shim tool (node.exe, npm.exe, etc.).\n//! It detects the tool name from its own filename, then spawns `vp.exe` with the\n//! `VITE_PLUS_SHIM_TOOL` environment variable set, allowing `vp.exe` to enter\n//! shim dispatch mode.\n//!\n//! On Ctrl+C, the trampoline ignores the signal (the child process handles it),\n//! avoiding the \"Terminate batch job (Y/N)?\" prompt that `.cmd` wrappers produce.\n//!\n//! **Size optimization**: This binary avoids `core::fmt` (which adds ~100KB) by\n//! never using `format!`, `eprintln!`, `println!`, or `.unwrap()`. All error\n//! paths use `process::exit(1)` directly.\n//!\n//! See: <https://github.com/voidzero-dev/vite-plus/issues/835>\n\nuse std::{\n    env,\n    process::{self, Command},\n};\n\nfn main() {\n    // 1. Determine tool name from our own executable filename\n    let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1));\n    let tool_name =\n        exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1));\n\n    // 2. Locate vp.exe: <bin_dir>/../current/bin/vp.exe\n    let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1));\n    let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1));\n    let vp_exe = vp_home.join(\"current\").join(\"bin\").join(\"vp.exe\");\n\n    // 3. Install Ctrl+C handler that ignores signals (child will handle them).\n    //    This prevents the \"Terminate batch job (Y/N)?\" prompt.\n    #[cfg(windows)]\n    install_ctrl_handler();\n\n    // 4. Spawn vp.exe\n    //    - Always set VITE_PLUS_HOME so vp.exe uses the correct home directory\n    //      (matches what the old .cmd wrappers did with %~dp0..)\n    //    - If tool is \"vp\", run in normal CLI mode (no VITE_PLUS_SHIM_TOOL)\n    //    - Otherwise, set VITE_PLUS_SHIM_TOOL so vp.exe enters shim dispatch\n    let mut cmd = Command::new(&vp_exe);\n    cmd.args(env::args_os().skip(1));\n    cmd.env(\"VITE_PLUS_HOME\", vp_home);\n\n    if tool_name != \"vp\" {\n        cmd.env(\"VITE_PLUS_SHIM_TOOL\", tool_name);\n        // Clear the recursion marker so nested shim invocations (e.g., npm\n        // spawning node) get fresh version resolution instead of falling\n        // through to passthrough mode. The old .cmd wrappers went through\n        // `vp env exec` which cleared this in exec.rs; the trampoline\n        // bypasses that path.\n        // Must match vite_shared::env_vars::VITE_PLUS_TOOL_RECURSION\n        cmd.env_remove(\"VITE_PLUS_TOOL_RECURSION\");\n    }\n\n    // 5. Execute and propagate exit code.\n    //    Use write_all instead of eprintln!/format! to avoid pulling in core::fmt (~100KB).\n    match cmd.status() {\n        Ok(s) => process::exit(s.code().unwrap_or(1)),\n        Err(_) => {\n            use std::io::Write;\n            let stderr = std::io::stderr();\n            let mut handle = stderr.lock();\n            let _ = handle.write_all(b\"vite-plus: failed to execute \");\n            let _ = handle.write_all(vp_exe.as_os_str().as_encoded_bytes());\n            let _ = handle.write_all(b\"\\n\");\n            process::exit(1);\n        }\n    }\n}\n\n/// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc.\n///\n/// When Ctrl+C is pressed, Windows sends the event to all processes in the\n/// console group. By returning TRUE (1), we tell Windows we handled the event\n/// (by ignoring it). The child process also receives the event and can\n/// decide how to respond (typically by exiting gracefully).\n///\n/// This is the same pattern used by uv-trampoline and Python's distlib launcher.\n#[cfg(windows)]\nfn install_ctrl_handler() {\n    // Raw FFI declaration to avoid pulling in the heavy `windows`/`windows-core` crates.\n    // Signature: https://learn.microsoft.com/en-us/windows/console/setconsolectrlhandler\n    type HandlerRoutine = unsafe extern \"system\" fn(ctrl_type: u32) -> i32;\n    unsafe extern \"system\" {\n        fn SetConsoleCtrlHandler(handler: Option<HandlerRoutine>, add: i32) -> i32;\n    }\n\n    unsafe extern \"system\" fn handler(_ctrl_type: u32) -> i32 {\n        1 // TRUE - signal handled (ignored)\n    }\n\n    unsafe {\n        SetConsoleCtrlHandler(Some(handler), 1);\n    }\n}\n"
  },
  {
    "path": "deny.toml",
    "content": "# This template contains all of the possible sections and their default values\n\n# Note that all fields that take a lint level have these possible values:\n# * deny - An error will be produced and the check will fail\n# * warn - A warning will be produced, but the check will not fail\n# * allow - No warning or error will be produced, though in some cases a note\n# will be\n\n# The values provided in this template are the default values that will be used\n# when any section or field is not specified in your own configuration\n\n# This section is considered when running `cargo deny check advisories`\n# More documentation for the advisories section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html\n[advisories]\n# The path where the advisory database is cloned/fetched into\ndb-path = \"~/.cargo/advisory-db\"\n# The url(s) of the advisory databases to use\ndb-urls = [\"https://github.com/rustsec/advisory-db\"]\n# The lint level for crates that have been yanked from their source registry\nyanked = \"warn\"\n# A list of advisory IDs to ignore. Note that ignored advisories will still\n# output a note when they are encountered.\nignore = [\n  \"RUSTSEC-2024-0399\",\n  # Advisories from upstream (rolldown) dependencies\n  \"RUSTSEC-2025-0052\",\n  \"RUSTSEC-2025-0067\",\n  \"RUSTSEC-2025-0068\",\n  \"RUSTSEC-2025-0141\",\n  \"RUSTSEC-2026-0049\",\n  \"RUSTSEC-2026-0067\",\n  \"RUSTSEC-2026-0068\",\n]\n# Threshold for security vulnerabilities, any vulnerability with a CVSS score\n# lower than the range specified will be ignored. Note that ignored advisories\n# will still output a note when they are encountered.\n# * None - CVSS Score 0.0\n# * Low - CVSS Score 0.1 - 3.9\n# * Medium - CVSS Score 4.0 - 6.9\n# * High - CVSS Score 7.0 - 8.9\n# * Critical - CVSS Score 9.0 - 10.0\n# severity-threshold =\n\n# If this is true, then cargo deny will use the git executable to fetch advisory database.\n# If this is false, then it uses a built-in git library.\n# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.\n# See Git Authentication for more information about setting up git authentication.\n# git-fetch-with-cli = true\n\n# This section is considered when running `cargo deny check licenses`\n# More documentation for the licenses section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html\n[licenses]\n# List of explicitly allowed licenses\n# See https://spdx.org/licenses/ for list of possible licenses\n# [possible values: any SPDX 3.11 short identifier (+ optional exception)].\nallow = [\n  \"Apache-2.0\",\n  \"BSD-2-Clause\",\n  \"BSD-3-Clause\",\n  \"BSL-1.0\",\n  \"bzip2-1.0.6\",\n  \"CC0-1.0\",\n  \"CDLA-Permissive-2.0\",\n  \"ISC\",\n  \"MIT\",\n  \"MIT-0\",\n  \"MPL-2.0\",\n  \"OpenSSL\",\n  \"Unicode-DFS-2016\",\n  \"Unicode-3.0\",\n  \"Zlib\",\n]\n# The confidence threshold for detecting a license from license text.\n# The higher the value, the more closely the license text must be to the\n# canonical license text of a valid SPDX license file.\n# [possible values: any between 0.0 and 1.0].\nconfidence-threshold = 0.8\n# Allow 1 or more licenses on a per-crate basis, so that particular licenses\n# aren't accepted for every possible crate as with the normal allow list\nexceptions = [\n\n  # Each entry is the crate and version constraint, and its specific allow\n  # list\n  # { allow = [\"Zlib\"], name = \"adler32\", version = \"*\" },\n]\n\n# Some crates don't have (easily) machine readable licensing information,\n# adding a clarification entry for it allows you to manually specify the\n# licensing information\n[[licenses.clarify]]\n# The name of the crate the clarification applies to\nname = \"ring\"\n# The optional version constraint for the crate\nversion = \"*\"\n# The SPDX expression for the license requirements of the crate\nexpression = \"MIT AND ISC AND OpenSSL\"\n# One or more files in the crate's source used as the \"source of truth\" for\n# the license expression. If the contents match, the clarification will be used\n# when running the license check, otherwise the clarification will be ignored\n# and the crate will be checked normally, which may produce warnings or errors\n# depending on the rest of your configuration\nlicense-files = [\n  # Each entry is a crate relative path, and the (opaque) hash of its contents\n  { path = \"LICENSE\", hash = 0xbd0eed23 },\n]\n\n[licenses.private]\n# If true, ignores workspace crates that aren't published, or are only\n# published to private registries.\n# To see how to mark a crate as unpublished (to the official registry),\n# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field.\nignore = false\n# One or more private registries that you might publish crates to, if a crate\n# is only published to private registries, and ignore is true, the crate will\n# not have its license(s) checked\nregistries = [\n\n  # \"https://sekretz.com/registry\n]\n\n# This section is considered when running `cargo deny check bans`.\n# More documentation about the 'bans' section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html\n[bans]\n# Lint level for when multiple versions of the same crate are detected\nmultiple-versions = \"warn\"\n# Lint level for when a crate version requirement is `*`\nwildcards = \"allow\"\n# The graph highlighting used when creating dotgraphs for crates\n# with multiple versions\n# * lowest-version - The path to the lowest versioned duplicate is highlighted\n# * simplest-path - The path to the version with the fewest edges is highlighted\n# * all - Both lowest-version and simplest-path are used\nhighlight = \"all\"\n# The default lint level for `default` features for crates that are members of\n# the workspace that is being checked. This can be overridden by allowing/denying\n# `default` on a crate-by-crate basis if desired.\nworkspace-default-features = \"allow\"\n# The default lint level for `default` features for external crates that are not\n# members of the workspace. This can be overridden by allowing/denying `default`\n# on a crate-by-crate basis if desired.\nexternal-default-features = \"allow\"\n# List of crates that are allowed. Use with care!\nallow = [\n\n  # { name = \"ansi_term\", version = \"=0.11.0\" },\n]\n# List of crates to deny\ndeny = [\n\n  # Each entry the name of a crate and a version range. If version is\n  # not specified, all versions will be matched.\n  # { name = \"ansi_term\", version = \"=0.11.0\" },\n  #\n  # Wrapper crates can optionally be specified to allow the crate when it\n  # is a direct dependency of the otherwise banned crate\n  # { name = \"ansi_term\", version = \"=0.11.0\", wrappers = [] },\n]\n\n# List of features to allow/deny\n# Each entry the name of a crate and a version range. If version is\n# not specified, all versions will be matched.\n# [[bans.features]]\n# name = \"reqwest\"\n# Features to not allow\n# deny = [\"json\"]\n# Features to allow\n# allow = [\n#    \"rustls\",\n#    \"__rustls\",\n#    \"__tls\",\n#    \"hyper-rustls\",\n#    \"rustls\",\n#    \"rustls-pemfile\",\n#    \"rustls-tls-webpki-roots\",\n#    \"tokio-rustls\",\n#    \"webpki-roots\",\n# ]\n# If true, the allowed features must exactly match the enabled feature set. If\n# this is set there is no point setting `deny`\n# exact = true\n\n# Certain crates/versions that will be skipped when doing duplicate detection.\nskip = [\n\n  # { name = \"ansi_term\", version = \"=0.11.0\" },\n]\n# Similarly to `skip` allows you to skip certain crates during duplicate\n# detection. Unlike skip, it also includes the entire tree of transitive\n# dependencies starting at the specified crate, up to a certain depth, which is\n# by default infinite.\nskip-tree = [\n\n  # { name = \"ansi_term\", version = \"=0.11.0\", depth = 20 },\n]\n\n# This section is considered when running `cargo deny check sources`.\n# More documentation about the 'sources' section can be found here:\n# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html\n[sources]\n# Lint level for what to happen when a crate from a crate registry that is not\n# in the allow list is encountered\nunknown-registry = \"warn\"\n# Lint level for what to happen when a crate from a git repository that is not\n# in the allow list is encountered\nunknown-git = \"warn\"\n# List of URLs for allowed crate registries. Defaults to the crates.io index\n# if not specified. If it is specified but empty, no registries are allowed.\nallow-registry = [\"https://github.com/rust-lang/crates.io-index\"]\n# List of URLs for allowed Git repositories\nallow-git = []\n\n[sources.allow-org]\n# 1 or more github.com organizations to allow git sources for\ngithub = [\"voidzero-dev\", \"reubeno\", \"polachok\", \"branchseer\"]\n# 1 or more gitlab.com organizations to allow git sources for\n# gitlab = [\"\"]\n# 1 or more bitbucket.org organizations to allow git sources for\n# bitbucket = [\"\"]\n\n[graph]\n# If 1 or more target triples (and optionally, target_features) are specified,\n# only the specified targets will be checked when running `cargo deny check`.\n# This means, if a particular package is only ever used as a target specific\n# dependency, such as, for example, the `nix` crate only being used via the\n# `target_family = \"unix\"` configuration, that only having windows targets in\n# this list would mean the nix crate, as well as any of its exclusive\n# dependencies not shared by any other crates, would be ignored, as the target\n# list here is effectively saying which targets you are building for.\ntargets = [\n\n  # The triple can be any string, but only the target triples built in to\n  # rustc (as of 1.40) can be checked against actual config expressions\n  # { triple = \"x86_64-unknown-linux-musl\" },\n  # You can also specify which target_features you promise are enabled for a\n  # particular target. target_features are currently not validated against\n  # the actual valid features supported by the target architecture.\n  # { triple = \"wasm32-unknown-unknown\", features = [\"atomics\"] },\n]\n# When creating the dependency graph used as the source of truth when checks are\n# executed, this field can be used to prune crates from the graph, removing them\n# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate\n# is pruned from the graph, all of its dependencies will also be pruned unless\n# they are connected to another crate in the graph that hasn't been pruned,\n# so it should be used with care. The identifiers are [Package ID Specifications]\n# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html)\n# First-party crates without license fields — exclude from all checks\nexclude = [\n  \"vite-plus-benches\",\n  \"vite-plus-cli\",\n  \"fspy\",\n  \"fspy_detours_sys\",\n  \"fspy_preload_unix\",\n  \"fspy_preload_windows\",\n  \"fspy_seccomp_unotify\",\n  \"fspy_shared\",\n  \"fspy_shared_unix\",\n]\n# If true, metadata will be collected with `--all-features`. Note that this can't\n# be toggled off if true, if you want to conditionally enable `--all-features` it\n# is recommended to pass `--all-features` on the cmd line instead\nall-features = false\n# If true, metadata will be collected with `--no-default-features`. The same\n# caveat with `all-features` applies\nno-default-features = false\n\n# If set, these feature will be enabled when collecting metadata. If `--features`\n# is specified on the cmd line they will take precedence over this option.\n# features = []\n\n[output]\n# When outputting inclusion graphs in diagnostics that include features, this\n# option can be used to specify the depth at which feature edges will be added.\n# This option is included since the graphs can be quite large and the addition\n# of features from the crate(s) to all of the graph roots can be far too verbose.\n# This option can be overridden via `--feature-depth` on the cmd line\nfeature-depth = 1\n"
  },
  {
    "path": "docs/.gitignore",
    "content": ".vitepress/cache\npublic/install.sh\npublic/install.ps1\n"
  },
  {
    "path": "docs/.vitepress/config.mts",
    "content": "import { resolve } from 'node:path';\n\nimport type { VoidZeroThemeConfig } from '@voidzero-dev/vitepress-theme';\nimport { extendConfig } from '@voidzero-dev/vitepress-theme/config';\nimport { defineConfig, type HeadConfig } from 'vitepress';\nimport { withMermaid } from 'vitepress-plugin-mermaid';\n\nconst taskRunnerGuideItems = [\n  {\n    text: 'Run',\n    link: '/guide/run',\n  },\n  {\n    text: 'Task Caching',\n    link: '/guide/cache',\n  },\n  {\n    text: 'Running Binaries',\n    link: '/guide/vpx',\n  },\n];\n\nconst guideSidebar = [\n  {\n    text: 'Introduction',\n    items: [\n      { text: 'Getting Started', link: '/guide/' },\n      { text: 'Creating a Project', link: '/guide/create' },\n      { text: 'Migrate to Vite+', link: '/guide/migrate' },\n      { text: 'Installing Dependencies', link: '/guide/install' },\n      { text: 'Environment', link: '/guide/env' },\n      { text: 'Why Vite+', link: '/guide/why' },\n    ],\n  },\n  {\n    text: 'Develop',\n    items: [\n      { text: 'Dev', link: '/guide/dev' },\n      {\n        text: 'Check',\n        link: '/guide/check',\n        items: [\n          { text: 'Lint', link: '/guide/lint' },\n          { text: 'Format', link: '/guide/fmt' },\n        ],\n      },\n      { text: 'Test', link: '/guide/test' },\n    ],\n  },\n  {\n    text: 'Execute',\n    items: taskRunnerGuideItems,\n  },\n  {\n    text: 'Build',\n    items: [\n      { text: 'Build', link: '/guide/build' },\n      { text: 'Pack', link: '/guide/pack' },\n    ],\n  },\n  {\n    text: 'Maintain',\n    items: [\n      { text: 'Upgrading Vite+', link: '/guide/upgrade' },\n      { text: 'Removing Vite+', link: '/guide/implode' },\n    ],\n  },\n  {\n    text: 'Workflow',\n    items: [\n      { text: 'IDE Integration', link: '/guide/ide-integration' },\n      { text: 'CI', link: '/guide/ci' },\n      { text: 'Commit Hooks', link: '/guide/commit-hooks' },\n      { text: 'Troubleshooting', link: '/guide/troubleshooting' },\n    ],\n  },\n];\n\nexport default extendConfig(\n  withMermaid(\n    defineConfig({\n      title: 'Vite+',\n      titleTemplate: ':title | The Unified Toolchain for the Web',\n      description: 'The Unified Toolchain for the Web',\n      cleanUrls: true,\n      head: [\n        ['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],\n        [\n          'link',\n          {\n            rel: 'preconnect',\n            href: 'https://fonts.gstatic.com',\n            crossorigin: 'true',\n          },\n        ],\n        ['meta', { name: 'theme-color', content: '#7474FB' }],\n        ['meta', { property: 'og:type', content: 'website' }],\n        ['meta', { property: 'og:site_name', content: 'Vite+' }],\n        ['meta', { name: 'twitter:card', content: 'summary_large_image' }],\n        ['meta', { name: 'twitter:site', content: '@voidzerodev' }],\n        [\n          'script',\n          {\n            src: 'https://cdn.usefathom.com/script.js',\n            'data-site': 'JFDLUWBH',\n            'data-spa': 'auto',\n            defer: '',\n          },\n        ],\n      ],\n      vite: {\n        resolve: {\n          tsconfigPaths: true,\n          alias: [\n            { find: '@local-assets', replacement: resolve(__dirname, 'theme/assets') },\n            { find: '@layouts', replacement: resolve(__dirname, 'theme/layouts') },\n            // dayjs ships CJS by default; redirect to its ESM build so\n            // mermaid (imported via vitepress-plugin-mermaid) works in dev\n            { find: /^dayjs$/, replacement: 'dayjs/esm' },\n          ],\n        },\n      },\n      themeConfig: {\n        variant: 'viteplus' as VoidZeroThemeConfig['variant'],\n        nav: [\n          {\n            text: 'Guide',\n            link: '/guide/',\n            activeMatch: '^/guide/',\n          },\n          {\n            text: 'Config',\n            link: '/config/',\n            activeMatch: '^/config/',\n          },\n          {\n            text: 'Resources',\n            items: [\n              { text: 'GitHub', link: 'https://github.com/voidzero-dev/vite-plus' },\n              { text: 'Releases', link: 'https://github.com/voidzero-dev/vite-plus/releases' },\n              {\n                text: 'Announcement',\n                link: 'https://voidzero.dev/posts/announcing-vite-plus-alpha',\n              },\n              {\n                text: 'Contributing',\n                link: 'https://github.com/voidzero-dev/vite-plus/blob/main/CONTRIBUTING.md',\n              },\n            ],\n          },\n        ],\n        sidebar: {\n          '/guide/': guideSidebar,\n          '/config/': [\n            {\n              text: 'Configuration',\n              items: [\n                { text: 'Configuring Vite+', link: '/config/' },\n                { text: 'Run', link: '/config/run' },\n                { text: 'Format', link: '/config/fmt' },\n                { text: 'Lint', link: '/config/lint' },\n                { text: 'Test', link: '/config/test' },\n                { text: 'Build', link: '/config/build' },\n                { text: 'Pack', link: '/config/pack' },\n                { text: 'Staged', link: '/config/staged' },\n              ],\n            },\n          ],\n        },\n        socialLinks: [\n          { icon: 'github', link: 'https://github.com/voidzero-dev/vite-plus' },\n          { icon: 'x', link: 'https://x.com/voidzerodev' },\n          { icon: 'discord', link: 'https://discord.gg/cC6TEVFKSx' },\n          { icon: 'bluesky', link: 'https://bsky.app/profile/voidzero.dev' },\n        ],\n        outline: {\n          level: [2, 3],\n        },\n        search: {\n          provider: 'local',\n        },\n      },\n      transformHead({ page, pageData }) {\n        const url = 'https://viteplus.dev/' + page.replace(/\\.md$/, '').replace(/index$/, '');\n\n        const canonicalUrlEntry: HeadConfig = [\n          'link',\n          {\n            rel: 'canonical',\n            href: url,\n          },\n        ];\n\n        const ogInfo: HeadConfig[] = [\n          ['meta', { property: 'og:title', content: pageData.frontmatter.title ?? 'Vite+' }],\n          [\n            'meta',\n            {\n              property: 'og:image',\n              content: `https://viteplus.dev/${pageData.frontmatter.cover ?? 'og.jpg'}`,\n            },\n          ],\n          ['meta', { property: 'og:url', content: url }],\n          [\n            'meta',\n            {\n              property: 'og:description',\n              content: pageData.frontmatter.description ?? 'The Unified Toolchain for the Web',\n            },\n          ],\n        ];\n\n        return [...ogInfo, canonicalUrlEntry];\n      },\n    }),\n  ),\n);\n"
  },
  {
    "path": "docs/.vitepress/env.d.ts",
    "content": "// Vue SFC module declaration\ndeclare module '*.vue' {\n  import type { DefineComponent } from 'vue';\n  const component: DefineComponent<{}, {}, unknown>;\n  export default component;\n}\n\n// CSS module declarations\ndeclare module '*.css' {}\n\n// Asset module declarations\ndeclare module '*.riv' {\n  const src: string;\n  export default src;\n}\n\ndeclare module '*.svg' {\n  const src: string;\n  export default src;\n}\n\ndeclare module '*.png' {\n  const src: string;\n  export default src;\n}\n\ndeclare module '*.jpg' {\n  const src: string;\n  export default src;\n}\n"
  },
  {
    "path": "docs/.vitepress/theme/Layout.vue",
    "content": "<script setup lang=\"ts\">\nimport OSSHeader from '@components/oss/Header.vue';\nimport BaseTheme from '@voidzero-dev/vitepress-theme/src/viteplus';\nimport { useData } from 'vitepress';\nimport { nextTick, onUnmounted, watch } from 'vue';\n\nimport Footer from './components/Footer.vue';\nimport Home from './layouts/Home.vue';\n// import Error404 from \"./layouts/Error404.vue\";\n\nconst { frontmatter, isDark } = useData();\nconst { Layout: BaseLayout } = BaseTheme;\nlet homeHeaderObserver: MutationObserver | null = null;\n\nconst syncHeaderMobileMenuTheme = (header: HTMLElement | null, isHome: boolean) => {\n  const mobileMenu = header?.querySelector<HTMLElement>('#mobile-menu');\n\n  if (!mobileMenu) {\n    return;\n  }\n\n  if (isHome) {\n    mobileMenu.setAttribute('data-theme', 'light');\n  } else {\n    mobileMenu.removeAttribute('data-theme');\n  }\n};\n\nconst setupHomeHeaderObserver = (header: HTMLElement | null, isHome: boolean) => {\n  homeHeaderObserver?.disconnect();\n  homeHeaderObserver = null;\n\n  if (!header || !isHome || typeof MutationObserver === 'undefined') {\n    return;\n  }\n\n  homeHeaderObserver = new MutationObserver(() => {\n    syncHeaderMobileMenuTheme(header, isHome);\n  });\n\n  homeHeaderObserver.observe(header, {\n    childList: true,\n    subtree: true,\n  });\n};\n\nconst syncHomeThemeOverride = async () => {\n  if (typeof document === 'undefined') {\n    return;\n  }\n\n  const isHome = frontmatter.value?.layout === 'home';\n  const root = document.documentElement;\n\n  if (isHome) {\n    root.setAttribute('data-theme', 'light');\n  } else {\n    root.removeAttribute('data-theme');\n  }\n\n  await nextTick();\n\n  const header = document.querySelector<HTMLElement>('.home-header');\n\n  setupHomeHeaderObserver(header, isHome);\n\n  if (!header) {\n    return;\n  }\n\n  if (isHome) {\n    header.setAttribute('data-theme', 'light');\n  } else {\n    header.removeAttribute('data-theme');\n  }\n\n  syncHeaderMobileMenuTheme(header, isHome);\n};\n\nwatch(\n  [() => frontmatter.value?.layout, () => isDark.value],\n  () => {\n    void syncHomeThemeOverride();\n  },\n  { immediate: true },\n);\n\nonUnmounted(() => {\n  homeHeaderObserver?.disconnect();\n  homeHeaderObserver = null;\n});\n</script>\n\n<template>\n  <div v-if=\"frontmatter.layout === 'home'\" class=\"marketing-layout\">\n    <OSSHeader class=\"home-header\" />\n    <Home />\n    <Footer />\n  </div>\n  <BaseLayout v-else />\n</template>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/Footer.vue",
    "content": "<template>\n  <footer class=\"bg-primary\" data-theme=\"dark\">\n    <Sponsors\n      description=\"Vite+ is free and open source, made possible by a full-time team and passionate open-source contributors.\"\n      sponsorLinkText=\"Contribute\"\n      sponsorLink=\"https://github.com/voidzero-dev/vite-plus/blob/main/CONTRIBUTING.md\"\n    />\n    <section class=\"wrapper\">\n      <div\n        class=\"bg-wine bg-[url(/cta-background.jpg)] bg-cover py-16 md:py-30 px-5 md:px-0 overflow-clip flex flex-col items-center justify-center gap-8 md:gap-12\"\n      >\n        <h2 class=\"text-white w-full md:w-2xl text-center text-balance\">\n          Take your team's productivity to the next level with Vite+\n        </h2>\n        <div class=\"flex items-center gap-5\">\n          <a href=\"/guide\" target=\"_self\" class=\"button button--white\">Get started</a>\n        </div>\n      </div>\n      <div\n        class=\"px-5 md:px-24 pt-10 md:pt-16 pb-16 md:pb-30 flex flex-col md:flex-row gap-10 md:gap-0 md:justify-between\"\n      >\n        <div>\n          <p class=\"text-grey text-xs font-mono uppercase tracking-wide mb-8\">Company</p>\n          <ul class=\"flex flex-col gap-3\">\n            <li>\n              <a\n                href=\"https://voidzero.dev/\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base\"\n                >VoidZero</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://vite.dev/\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base\"\n                >Vite</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://vitest.dev/\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base\"\n                >Vitest</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://rolldown.rs/\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base\"\n                >Rolldown</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://oxc.rs/\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base\"\n                >Oxc</a\n              >\n            </li>\n          </ul>\n        </div>\n        <div>\n          <p class=\"text-grey text-xs font-mono uppercase tracking-wide mb-8\">Social</p>\n          <ul class=\"flex flex-col gap-3\">\n            <li>\n              <a\n                href=\"https://github.com/voidzero-dev\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base flex gap-3 items-center\"\n                ><Icon icon=\"simple-icons:github\" aria-label=\"GitHub\" class=\"size-[18px]\" />\n                GitHub</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://x.com/voidzerodev\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base flex gap-3 items-center\"\n                ><Icon icon=\"simple-icons:x\" aria-label=\"X\" class=\"size-[18px]\" />X.com</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://discord.gg/cC6TEVFKSx\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base flex gap-3 items-center\"\n                ><Icon\n                  icon=\"simple-icons:discord\"\n                  aria-label=\"Discord\"\n                  class=\"size-[18px]\"\n                />Discord</a\n              >\n            </li>\n            <li>\n              <a\n                href=\"https://bsky.app/profile/voidzero.dev\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                class=\"text-white text-base flex gap-3 items-center\"\n                ><Icon icon=\"simple-icons:bluesky\" aria-label=\"Bluesky\" class=\"size-[18px]\" />\n                Bluesky</a\n              >\n            </li>\n          </ul>\n        </div>\n      </div>\n    </section>\n    <section class=\"wrapper wrapper--ticks border-t py-5 px-5 md:px-24\">\n      <p class=\"text-sm\">\n        © {{ new Date().getFullYear() }} VoidZero Inc.\n        <span class=\"hidden sm:inline\">All Rights Reserved.</span>\n      </p>\n    </section>\n  </footer>\n</template>\n\n<style scoped></style>\n<script setup lang=\"ts\">\nimport Sponsors from '@components/oss/Sponsors.vue';\nimport { Icon } from '@iconify/vue';\n</script>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/CoreFeature3Col.vue",
    "content": "<script setup lang=\"ts\">\nimport nodeIcon from '@local-assets/icons/node.png';\nimport reactIcon from '@local-assets/icons/react.png';\nimport solidIcon from '@local-assets/icons/solid.png';\nimport svelteIcon from '@local-assets/icons/svelte.png';\nimport vueIcon from '@local-assets/icons/vue.png';\n\nimport StackedBlock from './StackedBlock.vue';\n</script>\n\n<template>\n  <section\n    class=\"wrapper border-t grid grid-rows-3 lg:grid-rows-1 lg:grid-cols-3 lg:h-80 divide-y lg:divide-y-0 lg:divide-x\"\n  >\n    <div class=\"p-5 lg:p-10 flex flex-col justify-between\">\n      <div class=\"flex flex-col gap-3 h-30 lg:h-auto\">\n        <h5>Manages your runtime and package manager</h5>\n        <p>\n          <span class=\"pr-1\">Use</span> <code>node</code> automatically, with the right package\n          manager selected for every project.\n        </p>\n      </div>\n      <div class=\"flex items-center gap-4 flex-wrap\">\n        <StackedBlock :src=\"nodeIcon\" alt=\"node\" href=\"https://nodejs.org\" />\n        <div class=\"flex flex-wrap gap-2\">\n          <code>pnpm</code>\n          <code>npm</code>\n          <code>yarn</code>\n        </div>\n      </div>\n    </div>\n    <div class=\"p-5 lg:p-10 flex flex-col justify-between\">\n      <div class=\"flex flex-col gap-3 h-30 lg:h-auto\">\n        <h5>Simplifies everyday development</h5>\n        <p>One configuration file and one consistent flow of commands across your whole stack.</p>\n      </div>\n      <div class=\"flex flex-wrap gap-2\">\n        <code>vp env</code>\n        <code>vp install</code>\n        <code>vp dev</code>\n        <code>vp check</code>\n        <code>vp build</code>\n        <code>vp run</code>\n      </div>\n    </div>\n    <div class=\"p-5 lg:p-10 flex flex-col justify-between\">\n      <div class=\"flex flex-col gap-3 h-30 lg:h-auto\">\n        <h5>Powering your favorite frameworks</h5>\n        <p>Supports every framework built on Vite.</p>\n      </div>\n      <div class=\"flex gap-3 items-center\">\n        <ul class=\"stacked-blocks\">\n          <StackedBlock :src=\"reactIcon\" alt=\"react\" href=\"https://react.dev\" />\n          <StackedBlock :src=\"vueIcon\" alt=\"vue\" href=\"https://vuejs.org\" />\n          <StackedBlock :src=\"svelteIcon\" alt=\"svelte\" href=\"https://svelte.dev\" />\n          <StackedBlock :src=\"solidIcon\" alt=\"solid\" href=\"https://solidjs.com\" />\n        </ul>\n        <p class=\"text-base text-primary\">+ 20 more</p>\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeatureCheck.vue",
    "content": "<script setup lang=\"ts\">\nimport oxcIcon from '@assets/icons/oxc-light.svg';\n</script>\n\n<template>\n  <section id=\"feature-check\" class=\"wrapper border-t grid md:grid-cols-2 divide-x divide-nickel\">\n    <div class=\"px-5 py-6 md:p-10 flex flex-col justify-between gap-15\">\n      <div class=\"flex flex-col gap-5\">\n        <span class=\"text-grey text-xs font-mono uppercase tracking-wide\">Vite+ check</span>\n        <h4 class=\"text-white\">Format, lint, and type-check in one pass</h4>\n        <p class=\"text-white/70 text-base max-w-[25rem] text-pretty\">\n          Keep every repo consistent with one command powered by Oxlint, Oxfmt, and\n          <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">tsgo</code>.\n        </p>\n        <ul class=\"checkmark-list\">\n          <li>\n            <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">Prettier</code> compatible\n            formatting\n          </li>\n          <li>\n            600+ <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">ESLint</code> compatible\n            rules\n          </li>\n          <li>\n            Type-aware linting and fast type checks with\n            <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">tsgo</code>\n          </li>\n          <li>\n            <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">vp check --fix</code> auto-fixes\n            where possible\n          </li>\n        </ul>\n      </div>\n      <div class=\"flex flex-wrap gap-3\">\n        <div class=\"px-3 py-1.5 bg-slate rounded w-fit flex gap-2 items-center\">\n          <span class=\"text-grey text-sm font-mono hidden md:inline\">Powered by</span>\n          <a href=\"https://oxc.rs/\" target=\"_blank\">\n            <figure class=\"project-icon\">\n              <img loading=\"lazy\" :src=\"oxcIcon\" alt=\"Oxc\" class=\"w-[20px] h-[12px]\" />\n              <figcaption>Oxc / Oxlint</figcaption>\n            </figure>\n          </a>\n        </div>\n        <div class=\"px-3 py-1.5 bg-slate rounded w-fit flex gap-2 items-center\">\n          <span class=\"text-grey text-sm font-mono hidden md:inline\">Powered by</span>\n          <a href=\"https://oxc.rs/docs/guide/usage/formatter\" target=\"_blank\">\n            <figure class=\"project-icon\">\n              <img loading=\"lazy\" :src=\"oxcIcon\" alt=\"Oxc\" class=\"w-[20px] h-[12px]\" />\n              <figcaption>Oxc / Oxfmt</figcaption>\n            </figure>\n          </a>\n        </div>\n      </div>\n    </div>\n    <div class=\"flex flex-col min-h-[22rem] sm:min-h-[30rem]\">\n      <div class=\"bg-oxc pl-10 h-full flex flex-col justify-center overflow-clip\">\n        <div\n          class=\"mr-5 sm:mr-10 px-5 py-6 relative bg-slate rounded-tl rounded-bl outline-1 outline-offset-[2px] outline-white/20 font-mono text-sm leading-[1.5rem] text-white\"\n        >\n          <div class=\"text-white\">$ vp check</div>\n          <div class=\"h-4\" />\n          <div>\n            <span class=\"terminal-blue\">pass:</span>\n            All <span class=\"text-white\">42 files</span> are correctly formatted\n            <span class=\"text-grey\">(88ms, 16 threads)</span>\n          </div>\n          <div class=\"h-4\" />\n          <div class=\"text-grey\">\n            <span class=\"terminal-blue\">pass:</span>\n            Found no warnings, lint errors, or type errors in\n            <span class=\"text-white\">42 files</span>\n            <span class=\"text-grey\">(184ms, 16 threads)</span>\n          </div>\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.bg-oxc {\n  background-image: url('@local-assets/backgrounds/oxc.jpg');\n  background-size: cover;\n  background-position: center;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeatureDevBuild.vue",
    "content": "<script setup lang=\"ts\">\nimport rolldownIcon from '@assets/icons/rolldown-light.svg';\nimport viteIcon from '@assets/icons/vite-light.svg';\n</script>\n\n<template>\n  <section\n    id=\"feature-dev-build\"\n    class=\"wrapper border-t grid md:grid-cols-2 divide-x divide-nickel\"\n  >\n    <div class=\"px-5 py-6 md:p-10 flex flex-col justify-between gap-15\">\n      <div class=\"flex flex-col gap-5\">\n        <span class=\"text-grey text-xs font-mono uppercase tracking-wide\"\n          >Vite+ dev &amp; build</span\n        >\n        <h4 class=\"text-white\">Blazingly fast builds</h4>\n        <p class=\"text-white/70 text-base max-w-[25rem] text-pretty\">\n          Spin up dev servers and create production builds with extreme speed. Stay in the flow and\n          keep CI fast.\n        </p>\n        <ul class=\"checkmark-list\">\n          <li>\n            Always instant\n            <code class=\"mr-1 outline-none bg-nickel/50 text-vite\"\n              >Hot Module Replacement (HMR)</code\n            >\n          </li>\n          <li>40× faster production build than webpack</li>\n          <li>Opt-in full-bundle dev mode for large apps</li>\n          <li>Huge ecosystem of plugins</li>\n        </ul>\n      </div>\n      <div class=\"px-3 py-1.5 bg-slate rounded w-fit flex gap-2 items-center\">\n        <span class=\"text-grey text-sm font-mono hidden md:inline\">Powered by</span>\n        <a href=\"https://vite.dev/\" target=\"_blank\">\n          <figure class=\"project-icon\">\n            <img loading=\"lazy\" :src=\"viteIcon\" alt=\"Vite\" class=\"w-[20px] h-[12px]\" />\n            <figcaption>Vite</figcaption>\n          </figure>\n        </a>\n        <span class=\"text-grey text-sm font-mono\">&amp;</span>\n        <a href=\"https://rolldown.rs/\" target=\"_blank\">\n          <figure class=\"project-icon\">\n            <img loading=\"lazy\" :src=\"rolldownIcon\" alt=\"Rolldown\" class=\"w-[20px] h-[12px]\" />\n            <figcaption>Rolldown</figcaption>\n          </figure>\n        </a>\n      </div>\n    </div>\n    <div class=\"flex flex-col min-h-[22rem] sm:min-h-[30rem]\">\n      <div class=\"bg-vite pl-10 h-full flex flex-col justify-center overflow-clip\">\n        <div\n          class=\"mr-5 sm:mr-10 px-5 py-6 relative bg-slate rounded-tl rounded-bl outline-1 outline-offset-[2px] outline-white/20 font-mono text-sm leading-[1.5rem] text-white\"\n        >\n          <div class=\"text-white\">$ vp build</div>\n          <div class=\"h-4\" />\n          <div class=\"text-grey\">\n            VITE+ <span class=\"terminal-blue\">building for production</span>\n          </div>\n          <div class=\"text-grey\">\n            <span class=\"text-zest\">✓</span> Transformed <span class=\"text-white\">128 modules</span>\n          </div>\n          <div class=\"h-4\" />\n          <div class=\"text-grey\">\n            <span class=\"text-white\">dist/index.html</span>\n            <span class=\"inline-block w-2\" aria-hidden=\"true\"></span>\n            <span class=\"terminal-blue\">0.42 kB</span>\n          </div>\n          <div class=\"text-grey\">\n            <span class=\"text-white\">dist/assets/index.css</span>\n            <span class=\"inline-block w-2\" aria-hidden=\"true\"></span>\n            <span class=\"terminal-blue\">5.1 kB</span>\n          </div>\n          <div class=\"text-grey\">\n            <span class=\"text-white\">dist/assets/index.js</span>\n            <span class=\"inline-block w-2\" aria-hidden=\"true\"></span>\n            <span class=\"terminal-blue\">46.2 kB</span>\n          </div>\n          <div class=\"text-grey\">\n            <span class=\"text-zest\">✓</span> Built in <span class=\"text-white\">421ms</span>\n          </div>\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.bg-vite {\n  background-image: url('@local-assets/backgrounds/vite.jpg');\n  background-size: cover;\n  background-position: center;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeaturePack.vue",
    "content": "<script setup lang=\"ts\">\nimport rolldownIcon from '@assets/icons/rolldown-light.svg';\n</script>\n\n<template>\n  <section id=\"feature-pack\" class=\"wrapper border-t grid md:grid-cols-2 md:divide-x divide-nickel\">\n    <div class=\"px-5 py-6 md:p-10 flex flex-col justify-between gap-15\">\n      <div class=\"flex flex-col gap-5\">\n        <span class=\"text-grey text-xs font-mono uppercase tracking-wide\">Vite+ pack</span>\n        <h4 class=\"text-white\">Library packaging with best practices baked in</h4>\n        <p class=\"text-white/70 text-base max-w-[25rem] text-pretty\">\n          Package TS and JS libraries for npm or build standalone app binaries with a single\n          <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">vp pack</code> command.\n        </p>\n        <ul class=\"checkmark-list\">\n          <li>\n            <code class=\"mx-1 outline-none bg-nickel/50 text-[#6CA1DB]\">DTS</code> generation &\n            bundling\n          </li>\n          <li>Automatic package exports generation</li>\n          <li>Standalone app binaries and transform-only unbundled mode</li>\n        </ul>\n      </div>\n      <div class=\"px-3 py-1.5 bg-slate rounded w-fit flex gap-2 items-center\">\n        <span class=\"text-grey text-sm font-mono hidden md:inline\">Powered by</span>\n        <a href=\"https://rolldown.rs/\" target=\"_blank\">\n          <figure class=\"project-icon\">\n            <img loading=\"lazy\" :src=\"rolldownIcon\" alt=\"Rolldown\" class=\"w-[20px] h-[12px]\" />\n            <figcaption>Rolldown</figcaption>\n          </figure>\n        </a>\n        <span>/</span>\n        <a href=\"https://tsdown.dev/\" target=\"_blank\"> tsdown </a>\n      </div>\n    </div>\n    <div class=\"flex flex-col\">\n      <div class=\"bg-rolldown h-full overflow-clip flex items-center py-20 px-5 md:pl-10 md:pr-0\">\n        <div\n          class=\"w-full mr-0 md:mr-10 px-5 py-6 relative bg-slate rounded-tl rounded-bl outline-1 outline-offset-[2px] outline-white/20 font-mono text-sm leading-[1.5rem] text-white\"\n        >\n          <div class=\"text-white\">$ vp pack</div>\n          <div class=\"h-4\" />\n          <div class=\"text-grey\">\n            CLI Building entry:\n            <span class=\"terminal-blue\">src/index.ts</span>\n          </div>\n          <div class=\"text-grey\">\n            CLI Using config:\n            <span class=\"text-white\">tsdown.config.ts</span>\n          </div>\n          <div class=\"text-grey\">\n            CLI tsdown <span class=\"text-white\">0.14.1</span> powered by Rolldown\n          </div>\n          <div class=\"h-4\" />\n          <div class=\"text-grey\">\n            ESM <span class=\"text-white\">dist/index.js</span>\n            <span class=\"inline-block w-2\" aria-hidden=\"true\"></span>\n            <span class=\"terminal-blue\">4.8 kB</span>\n          </div>\n          <div class=\"text-grey\">\n            DTS <span class=\"text-white\">dist/index.d.ts</span>\n            <span class=\"inline-block w-2\" aria-hidden=\"true\"></span>\n            <span class=\"terminal-blue\">1.2 kB</span>\n          </div>\n          <div class=\"text-grey\">\n            <span class=\"text-zest\">✓</span> Pack completed in <span class=\"text-white\">128ms</span>\n          </div>\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.bg-rolldown {\n  background-image: url('@local-assets/backgrounds/rolldown.jpg');\n  background-size: cover;\n  background-position: center;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeatureRun.vue",
    "content": "<script setup lang=\"ts\">\nimport FeatureRunTerminal from './FeatureRunTerminal.vue';\n</script>\n\n<template>\n  <section id=\"feature-run\" class=\"wrapper border-t grid md:grid-cols-2 md:divide-x divide-nickel\">\n    <div class=\"px-5 py-6 md:p-10 flex flex-col justify-between gap-15\">\n      <div class=\"flex flex-col gap-5\">\n        <span class=\"text-grey text-xs font-mono uppercase tracking-wide\">Vite+ run</span>\n        <h4 class=\"text-white\">Vite Task for monorepos and scripts</h4>\n        <p class=\"text-white/70 text-base max-w-[25rem] text-pretty\">\n          Run built-in commands and\n          <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">package.json</code> scripts with\n          automated caching and dependency-aware execution.\n        </p>\n        <ul class=\"checkmark-list\">\n          <li>Automated input tracking for cacheable tasks</li>\n          <li>Dependency-aware execution across workspace packages</li>\n          <li>\n            Familiar script execution via\n            <code class=\"mx-1 outline-none bg-nickel/50 text-aqua\">vp run</code>\n          </li>\n        </ul>\n      </div>\n    </div>\n    <div class=\"flex flex-col min-h-[22rem] sm:min-h-[30rem]\">\n      <div class=\"bg-viteplus pl-5 sm:pl-10 h-full overflow-clip flex flex-col justify-center py-6\">\n        <FeatureRunTerminal />\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.bg-viteplus {\n  background-image: url('@local-assets/backgrounds/viteplus.jpg');\n  background-size: cover;\n  background-position: center;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeatureRunTerminal.vue",
    "content": "<script setup lang=\"ts\">\nimport { TabsList, TabsRoot, TabsTrigger } from 'reka-ui';\nimport { computed, onMounted, onUnmounted, ref } from 'vue';\n\nimport { featureRunTranscripts } from '../../data/feature-run-transcripts';\nimport TerminalTranscript from './TerminalTranscript.vue';\n\nconst AUTO_ADVANCE_DELAY = 2400;\n\nconst activeStep = ref(featureRunTranscripts[0].id);\nconst autoPlayEnabled = ref(true);\nconst prefersReducedMotion = ref(false);\nconst hasEnteredViewport = ref(false);\nconst sectionRef = ref<HTMLElement | null>(null);\n\nlet autoAdvanceTimeout: ReturnType<typeof setTimeout> | null = null;\nlet observer: IntersectionObserver | null = null;\nlet mediaQuery: MediaQueryList | null = null;\n\nconst activeTranscript = computed(\n  () =>\n    featureRunTranscripts.find((transcript) => transcript.id === activeStep.value) ??\n    featureRunTranscripts[0],\n);\n\nconst clearAutoAdvance = () => {\n  if (autoAdvanceTimeout) {\n    clearTimeout(autoAdvanceTimeout);\n    autoAdvanceTimeout = null;\n  }\n};\n\nconst goToNextStep = () => {\n  const currentIndex = featureRunTranscripts.findIndex(\n    (transcript) => transcript.id === activeStep.value,\n  );\n  const nextIndex = (currentIndex + 1) % featureRunTranscripts.length;\n  activeStep.value = featureRunTranscripts[nextIndex].id;\n};\n\nconst onAnimationComplete = () => {\n  if (!autoPlayEnabled.value || prefersReducedMotion.value) {\n    return;\n  }\n\n  clearAutoAdvance();\n  autoAdvanceTimeout = setTimeout(() => {\n    goToNextStep();\n  }, AUTO_ADVANCE_DELAY);\n};\n\nconst onStepChange = () => {\n  clearAutoAdvance();\n  if (!prefersReducedMotion.value) {\n    autoPlayEnabled.value = true;\n    autoAdvanceTimeout = setTimeout(() => {\n      goToNextStep();\n    }, AUTO_ADVANCE_DELAY);\n  }\n};\n\nconst syncReducedMotionPreference = () => {\n  prefersReducedMotion.value = mediaQuery?.matches ?? false;\n  if (prefersReducedMotion.value) {\n    autoPlayEnabled.value = false;\n    clearAutoAdvance();\n  }\n};\n\nonMounted(() => {\n  if (typeof window !== 'undefined' && 'matchMedia' in window) {\n    mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');\n    syncReducedMotionPreference();\n    if ('addEventListener' in mediaQuery) {\n      mediaQuery.addEventListener('change', syncReducedMotionPreference);\n    } else {\n      mediaQuery.addListener(syncReducedMotionPreference);\n    }\n  }\n\n  if (!sectionRef.value || typeof IntersectionObserver === 'undefined') {\n    hasEnteredViewport.value = true;\n    return;\n  }\n\n  observer = new IntersectionObserver(\n    (entries) => {\n      entries.forEach((entry) => {\n        if (entry.isIntersecting && !hasEnteredViewport.value) {\n          hasEnteredViewport.value = true;\n          observer?.disconnect();\n        }\n      });\n    },\n    {\n      threshold: 0.35,\n      rootMargin: '0px',\n    },\n  );\n\n  observer.observe(sectionRef.value);\n});\n\nonUnmounted(() => {\n  clearAutoAdvance();\n  observer?.disconnect();\n  if (!mediaQuery) {\n    return;\n  }\n  if ('removeEventListener' in mediaQuery) {\n    mediaQuery.removeEventListener('change', syncReducedMotionPreference);\n  } else {\n    mediaQuery.removeListener(syncReducedMotionPreference);\n  }\n});\n</script>\n\n<template>\n  <div ref=\"sectionRef\" class=\"feature-run-terminal\">\n    <TabsRoot v-model=\"activeStep\" @update:modelValue=\"onStepChange\">\n      <div\n        class=\"px-4 sm:px-5 py-5 sm:py-6 relative bg-slate rounded-tl rounded-bl outline-1 outline-offset-[2px] outline-white/20\"\n      >\n        <TerminalTranscript\n          v-if=\"hasEnteredViewport\"\n          :key=\"activeTranscript.id\"\n          :transcript=\"activeTranscript\"\n          :animate=\"!prefersReducedMotion\"\n          @complete=\"onAnimationComplete\"\n        />\n      </div>\n      <TabsList\n        aria-label=\"Vite Task cache examples\"\n        class=\"run-step-picker flex items-center p-1 rounded-md border border-white/10 bg-[#111]\"\n      >\n        <TabsTrigger\n          v-for=\"transcript in featureRunTranscripts\"\n          :key=\"transcript.id\"\n          :value=\"transcript.id\"\n        >\n          {{ transcript.label }}\n        </TabsTrigger>\n      </TabsList>\n    </TabsRoot>\n  </div>\n</template>\n\n<style scoped>\n.feature-run-terminal {\n  width: 100%;\n  margin-right: 0;\n}\n\n.run-step-picker {\n  width: fit-content;\n  gap: 0.5rem;\n  margin: 0.9rem auto 0;\n}\n\n:deep(.terminal-copy) {\n  min-height: 12.5rem;\n  font-size: 0.8125rem;\n  line-height: 1.35rem;\n}\n\n:deep(.terminal-spacer) {\n  height: 0.75rem;\n}\n\n@media (min-width: 640px) {\n  .feature-run-terminal {\n    margin-right: 2.5rem;\n  }\n\n  :deep(.terminal-copy) {\n    font-size: 0.875rem;\n    line-height: 1.5rem;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeatureTest.vue",
    "content": "<script setup lang=\"ts\">\nimport vitestIcon from '@assets/icons/vitest-light.svg';\nimport testTerminal from '@local-assets/terminal-features/test.svg';\n</script>\n<template>\n  <section id=\"feature-test\" class=\"wrapper border-t grid md:grid-cols-2 divide-x divide-nickel\">\n    <div class=\"px-5 py-6 md:p-10 flex flex-col justify-between gap-15\">\n      <div class=\"flex flex-col gap-5\">\n        <span class=\"text-grey text-xs font-mono uppercase tracking-wide\">Vite+ test</span>\n        <h4 class=\"text-white\">Testing made simple</h4>\n        <p class=\"text-white/70 text-base max-w-[25rem] text-pretty\">\n          Feature rich test runner that automatically reuses the same resolve and transform config\n          from your application.\n        </p>\n        <ul class=\"checkmark-list\">\n          <li><code class=\"mr-1 outline-none bg-nickel/50 text-zest\">Jest</code> compatible API</li>\n          <li>Test isolation by default</li>\n          <li>Browser Mode: run unit tests in actual browsers</li>\n          <li>Coverage reports, snapshot tests, type tests, visual regression tests...</li>\n        </ul>\n      </div>\n      <div class=\"px-3 py-1.5 bg-slate rounded w-fit flex gap-2 items-center\">\n        <span class=\"text-grey text-sm font-mono hidden md:inline\">Powered by</span>\n        <a href=\"https://vitest.dev/\" target=\"_blank\">\n          <figure class=\"project-icon\">\n            <img loading=\"lazy\" :src=\"vitestIcon\" alt=\"Vitest\" class=\"w-[20px] h-[12px]\" />\n            <figcaption>Vitest</figcaption>\n          </figure>\n        </a>\n      </div>\n    </div>\n    <div class=\"flex flex-col min-h-[22rem] sm:min-h-[30rem]\">\n      <div class=\"bg-vitest pl-10 h-full flex flex-col justify-center overflow-clip\">\n        <div\n          class=\"block px-5 py-6 relative bg-slate rounded-tl rounded-bl outline-1 outline-offset-[2px] outline-white/20\"\n        >\n          <img loading=\"lazy\" :src=\"testTerminal\" alt=\"vp test terminal command\" />\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.bg-vitest {\n  background-image: url('@local-assets/backgrounds/vitest.jpg');\n  background-size: cover;\n  background-position: center;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/FeatureToolbar.vue",
    "content": "<script setup lang=\"ts\">\nimport { ref, onMounted, onUnmounted } from 'vue';\n\nconst features = [\n  { id: 'feature-dev-build', label: 'dev & build' },\n  { id: 'feature-check', label: 'check' },\n  { id: 'feature-test', label: 'test' },\n  { id: 'feature-run', label: 'run' },\n  { id: 'feature-pack', label: 'pack' },\n];\n\nconst activeSection = ref('feature-dev-build');\nconst underlineStyle = ref({ left: '0px', width: '0px' });\nconst listItems = ref<HTMLElement[]>([]);\nlet scrollTimeout: number | null = null;\n\nconst scrollToSection = (e: Event, id: string) => {\n  e.preventDefault();\n  e.stopPropagation();\n\n  const element = document.getElementById(id);\n  if (!element) {\n    return;\n  }\n\n  // Get the toolbar height to offset the scroll\n  const toolbar = (e.currentTarget as HTMLElement).closest('section');\n  const toolbarHeight = toolbar?.offsetHeight || 0;\n\n  // Calculate position to scroll to\n  const elementPosition = element.getBoundingClientRect().top + window.scrollY;\n  const offsetPosition = elementPosition - toolbarHeight;\n\n  // Use custom smooth scroll with requestAnimationFrame for guaranteed smooth behavior\n  const startPosition = window.scrollY;\n  const distance = offsetPosition - startPosition;\n  const duration = 800; // ms\n  let startTime: number | null = null;\n\n  const animation = (currentTime: number) => {\n    if (startTime === null) {\n      startTime = currentTime;\n    }\n    const timeElapsed = currentTime - startTime;\n    const progress = Math.min(timeElapsed / duration, 1);\n\n    // Easing function (easeInOutCubic)\n    const ease =\n      progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;\n\n    window.scrollTo(0, startPosition + distance * ease);\n\n    if (progress < 1) {\n      requestAnimationFrame(animation);\n    }\n  };\n\n  requestAnimationFrame(animation);\n};\n\nconst updateUnderlinePosition = () => {\n  const activeIndex = features.findIndex((f) => f.id === activeSection.value);\n  if (activeIndex >= 0 && listItems.value[activeIndex]) {\n    const activeItem = listItems.value[activeIndex];\n    underlineStyle.value = {\n      left: `${activeItem.offsetLeft}px`,\n      width: `${activeItem.offsetWidth}px`,\n    };\n\n    // Auto-scroll the toolbar on mobile to keep active item in view\n    const toolbar = activeItem.closest('ul');\n    if (toolbar && window.innerWidth < 640) {\n      // sm breakpoint\n      const itemLeft = activeItem.offsetLeft;\n      const itemWidth = activeItem.offsetWidth;\n      const toolbarWidth = toolbar.clientWidth;\n\n      // Calculate the center position\n      const targetScrollLeft = itemLeft - toolbarWidth / 2 + itemWidth / 2;\n\n      toolbar.scrollTo({\n        left: targetScrollLeft,\n        behavior: 'smooth',\n      });\n    }\n  }\n};\n\nconst determineActiveSection = () => {\n  // Get all sections and their positions\n  const sections = features\n    .map((feature) => {\n      const element = document.getElementById(feature.id);\n      if (!element) {\n        return null;\n      }\n\n      const rect = element.getBoundingClientRect();\n      return {\n        id: feature.id,\n        top: rect.top,\n        bottom: rect.bottom,\n        height: rect.height,\n      };\n    })\n    .filter(Boolean);\n\n  // Find the section that's most visible near the top of the viewport\n  // We consider a section \"active\" if it's within 200px of the top\n  const threshold = 200;\n  let activeId = activeSection.value;\n\n  for (const section of sections) {\n    if (!section) {\n      continue;\n    }\n\n    // If section top is near or above threshold and bottom is below threshold\n    if (section.top <= threshold && section.bottom > threshold) {\n      activeId = section.id;\n      break;\n    }\n  }\n\n  // If no section found above, check if we're at the very bottom\n  if (activeId === activeSection.value) {\n    const lastSection = sections[sections.length - 1];\n    if (\n      lastSection &&\n      window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 100\n    ) {\n      activeId = lastSection.id;\n    }\n  }\n\n  if (activeId !== activeSection.value) {\n    activeSection.value = activeId;\n    updateUnderlinePosition();\n  }\n};\n\nconst handleScroll = () => {\n  if (scrollTimeout) {\n    window.cancelAnimationFrame(scrollTimeout);\n  }\n\n  scrollTimeout = window.requestAnimationFrame(() => {\n    determineActiveSection();\n  });\n};\n\nlet observer: IntersectionObserver | null = null;\n\nonMounted(() => {\n  // Set up scroll listener for active state tracking\n  window.addEventListener('scroll', handleScroll, { passive: true });\n\n  // Initial underline position\n  setTimeout(() => {\n    updateUnderlinePosition();\n    determineActiveSection();\n  }, 100);\n\n  // Update on resize\n  window.addEventListener('resize', updateUnderlinePosition);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('scroll', handleScroll);\n  window.removeEventListener('resize', updateUnderlinePosition);\n  if (scrollTimeout) {\n    window.cancelAnimationFrame(scrollTimeout);\n  }\n});\n</script>\n\n<template>\n  <div class=\"wrapper wrapper wrapper--ticks border-t w-full relative z-20\"></div>\n  <section class=\"wrapper sticky top-0 border-b bg-primary z-10 overflow-hidden\">\n    <ul\n      class=\"w-full sm:grid sm:grid-cols-5 flex items-center divide-x divide-nickel relative overflow-x-auto scrollbar-hide touch-none sm:touch-auto select-none sm:select-auto\"\n    >\n      <div\n        class=\"absolute bottom-0 h-0.5 bg-white transition-all duration-300 ease-out\"\n        :style=\"underlineStyle\"\n      />\n      <li\n        v-for=\"(feature, index) in features\"\n        :key=\"feature.id\"\n        ref=\"listItems\"\n        class=\"flex-shrink-0\"\n      >\n        <a\n          :href=\"`#${feature.id}`\"\n          @click=\"scrollToSection($event, feature.id)\"\n          class=\"h-full text-sm font-mono tracking-tight py-4 px-6 sm:px-0 flex justify-center gap-1.5 transition-colors duration-200 whitespace-nowrap\"\n          :class=\"activeSection === feature.id ? 'text-white' : 'text-grey'\"\n        >\n          <svg\n            v-if=\"index === 0\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M3.26172 12.6653C3.26172 13.0335 3.5602 13.332 3.92839 13.332H4.59505C4.96324 13.332 5.26172 13.0335 5.26172 12.6653V5.9987C5.26172 5.6305 4.96324 5.33203 4.59505 5.33203H3.92839C3.5602 5.33203 3.26172 5.6305 3.26172 5.9987V12.6653Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"square\"\n            />\n            <path\n              d=\"M7.92847 12.592C7.92847 13.0013 8.22693 13.333 8.59513 13.333H9.2618C9.63 13.333 9.92847 13.0013 9.92847 12.592V2.21921C9.92847 1.81001 9.63 1.47827 9.2618 1.47827H8.59513C8.22693 1.47827 7.92847 1.81 7.92847 2.21921V12.592Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"square\"\n            />\n            <path\n              d=\"M12.5952 12.6654C12.5952 13.0336 12.8937 13.332 13.2619 13.332H13.9285C14.2967 13.332 14.5952 13.0336 14.5952 12.6654V7.9987C14.5952 7.63056 14.2967 7.33203 13.9285 7.33203H13.2619C12.8937 7.33203 12.5952 7.63056 12.5952 7.9987V12.6654Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"square\"\n            />\n          </svg>\n          <svg\n            v-else-if=\"index === 2\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M9.97939 1.33325V2.99073C9.97939 4.15603 9.97939 4.73868 10.1221 5.29802C10.2647 5.85736 10.5443 6.37094 11.1035 7.39812L11.8613 8.79005C13.2863 11.4076 13.9988 12.7164 13.4143 13.6866L13.4052 13.7014C12.8121 14.6666 11.3033 14.6666 8.28572 14.6666C5.2681 14.6666 3.7593 14.6666 3.16626 13.7014L3.15722 13.6866C2.57269 12.7164 3.28518 11.4076 4.71016 8.79005L5.46792 7.39812C6.02712 6.37094 6.30672 5.85736 6.44938 5.29802C6.59204 4.73868 6.59204 4.15603 6.59204 2.99073V1.33325\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n            />\n            <path\n              d=\"M5.6189 1.33325H10.9522\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n            />\n            <path\n              d=\"M5.28564 7.70432C5.95231 6.93539 7.01858 7.48965 8.28564 8.21225C9.95231 9.16272 10.9523 8.43345 11.2856 7.74359\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-linecap=\"round\"\n            />\n          </svg>\n          <svg\n            v-else-if=\"index === 1\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <g clip-path=\"url(#clip0_302_1655)\">\n              <path\n                d=\"M10.1427 1.33325L10.5019 2.92763C10.8065 4.28002 11.8626 5.33613 13.215 5.64077L14.8094 5.99992L13.215 6.35907C11.8626 6.66371 10.8065 7.71979 10.5019 9.07219L10.1427 10.6666L9.78361 9.07219C9.47894 7.71979 8.42287 6.66371 7.07047 6.35907L5.47607 5.99992L7.07047 5.64077C8.42281 5.33613 9.47894 4.28002 9.78361 2.92764L10.1427 1.33325Z\"\n                :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n                stroke-width=\"1.2\"\n                stroke-linejoin=\"round\"\n              />\n              <path\n                d=\"M4.80941 8L5.06595 9.13887C5.28355 10.1048 6.03791 10.8592 7.00387 11.0768L8.14274 11.3333L7.00387 11.5899C6.03791 11.8075 5.28355 12.5618 5.06595 13.5278L4.80941 14.6667L4.55287 13.5278C4.33527 12.5618 3.58091 11.8075 2.61492 11.5899L1.47607 11.3333L2.61492 11.0768C3.58091 10.8592 4.33527 10.1049 4.55287 9.13887L4.80941 8Z\"\n                :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n                stroke-width=\"1.2\"\n                stroke-linejoin=\"round\"\n              />\n            </g>\n            <defs>\n              <clipPath id=\"clip0_302_1655\">\n                <rect width=\"16\" height=\"16\" fill=\"white\" transform=\"translate(0.142822)\" />\n              </clipPath>\n            </defs>\n          </svg>\n          <svg\n            v-else-if=\"index === 3\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M7.16634 2.66659C7.16634 1.93021 7.76327 1.33325 8.49967 1.33325H9.16634C9.90274 1.33325 10.4997 1.93021 10.4997 2.66659V4.36883C10.4997 5.24394 11.0686 6.01743 11.904 6.27807L12.4287 6.44177C13.2641 6.70239 13.833 7.47592 13.833 8.35099V9.33325C13.833 9.70145 13.5345 9.99992 13.1663 9.99992H4.49967C4.13149 9.99992 3.83301 9.70145 3.83301 9.33325V8.35099C3.83301 7.47592 4.40193 6.70239 5.23733 6.44177L5.76202 6.27807C6.59741 6.01743 7.16634 5.24394 7.16634 4.36883V2.66659Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n            />\n            <path\n              d=\"M4.50128 10C4.60515 10.8721 4.16364 13.0088 3.1665 14.5786C3.1665 14.5786 10.0281 15.3729 10.9566 11.9623V13.2475C10.9566 13.875 10.9566 14.1887 11.152 14.3837C11.5279 14.7585 13.2926 14.7687 13.669 14.3681C13.8668 14.1575 13.847 13.8546 13.8072 13.2487C13.7418 12.2497 13.562 10.9705 13.068 10\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n            />\n          </svg>\n          <svg\n            v-else-if=\"index === 4\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M11.1328 8.26328C11.0318 8.68108 10.5545 8.97635 9.6 9.56688C8.6772 10.1377 8.21586 10.4231 7.844 10.3084C7.69029 10.2609 7.55023 10.1709 7.43728 10.0467C7.16406 9.74661 7.16406 9.16441 7.16406 8.00008C7.16406 6.83575 7.16406 6.25353 7.43728 5.95338C7.55023 5.82929 7.69029 5.7392 7.844 5.69177C8.21586 5.57704 8.6772 5.86246 9.6 6.4333C10.5545 7.02381 11.0318 7.31908 11.1328 7.73688C11.1745 7.90935 11.1745 8.09081 11.1328 8.26328Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linejoin=\"round\"\n            />\n            <path\n              d=\"M2.52393 8.00008C2.52393 5.01452 2.52393 3.52174 3.45142 2.59424C4.37892 1.66675 5.8717 1.66675 8.85726 1.66675C11.8428 1.66675 13.3356 1.66675 14.2631 2.59424C15.1906 3.52174 15.1906 5.01452 15.1906 8.00008C15.1906 10.9856 15.1906 12.4784 14.2631 13.4059C13.3356 14.3334 11.8428 14.3334 8.85726 14.3334C5.8717 14.3334 4.37892 14.3334 3.45142 13.4059C2.52393 12.4784 2.52393 10.9856 2.52393 8.00008Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n            />\n          </svg>\n          <svg\n            v-else-if=\"index === 5\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M2.38086 8.00008C2.38086 5.01452 2.38086 3.52174 3.30835 2.59424C4.23585 1.66675 5.72863 1.66675 8.71419 1.66675C11.6997 1.66675 13.1925 1.66675 14.1201 2.59424C15.0475 3.52174 15.0475 5.01452 15.0475 8.00008C15.0475 10.9856 15.0475 12.4784 14.1201 13.4059C13.1925 14.3334 11.6997 14.3334 8.71419 14.3334C5.72863 14.3334 4.23585 14.3334 3.30835 13.4059C2.38086 12.4784 2.38086 10.9856 2.38086 8.00008Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linejoin=\"round\"\n            />\n            <path\n              d=\"M6.38086 6.66675C5.82857 6.66675 5.38086 6.21903 5.38086 5.66675C5.38086 5.11446 5.82857 4.66675 6.38086 4.66675C6.93315 4.66675 7.38086 5.11446 7.38086 5.66675C7.38086 6.21903 6.93315 6.66675 6.38086 6.66675Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n            />\n            <path\n              d=\"M11.0474 11.3333C11.5996 11.3333 12.0474 10.8855 12.0474 10.3333C12.0474 9.78099 11.5996 9.33325 11.0474 9.33325C10.4951 9.33325 10.0474 9.78099 10.0474 10.3333C10.0474 10.8855 10.4951 11.3333 11.0474 11.3333Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n            />\n            <path\n              d=\"M7.38086 5.66675H12.0475\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"round\"\n            />\n            <path\n              d=\"M10.0475 10.3333H5.38086\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"round\"\n            />\n          </svg>\n          <svg\n            v-else-if=\"index === 6\"\n            class=\"w-4\"\n            viewBox=\"0 0 17 16\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n          >\n            <path\n              d=\"M11.4131 7H5.72946C4.00843 7 3.14792 7 2.75224 7.5684C2.35658 8.1368 2.65065 8.95053 3.2388 10.578L3.96159 12.578C4.26826 13.4265 4.42159 13.8509 4.76389 14.0921C5.10619 14.3333 5.55488 14.3333 6.45224 14.3333H10.6904C11.5877 14.3333 12.0364 14.3333 12.3787 14.0921C12.721 13.8509 12.8743 13.4265 13.181 12.578L13.9038 10.578C14.492 8.95053 14.786 8.1368 14.3904 7.5684C13.9947 7 13.1342 7 11.4131 7Z\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"square\"\n            />\n            <path\n              d=\"M13.2379 5.33325C13.2379 5.02263 13.2379 4.86731 13.1871 4.7448C13.1195 4.58145 12.9897 4.45166 12.8263 4.384C12.7038 4.33325 12.5485 4.33325 12.2379 4.33325H4.90454C4.59391 4.33325 4.4386 4.33325 4.31609 4.384C4.15273 4.45166 4.02295 4.58145 3.95529 4.7448C3.90454 4.86731 3.90454 5.02263 3.90454 5.33325\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n            />\n            <path\n              d=\"M11.5713 2.66675C11.5713 2.35612 11.5713 2.20081 11.5206 2.07829C11.4529 1.91494 11.3231 1.78515 11.1598 1.71749C11.0372 1.66675 10.8819 1.66675 10.5713 1.66675H6.57129C6.26066 1.66675 6.10535 1.66675 5.98284 1.71749C5.81948 1.78515 5.6897 1.91494 5.62204 2.07829C5.57129 2.20081 5.57129 2.35612 5.57129 2.66675\"\n              :stroke=\"activeSection === feature.id ? 'white' : '#827A89'\"\n              stroke-width=\"1.2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n            />\n          </svg>\n          <span>{{ feature.label }}</span>\n        </a>\n      </li>\n    </ul>\n  </section>\n</template>\n\n<style scoped>\n/* Hide scrollbar for Chrome, Safari and Opera */\n.scrollbar-hide::-webkit-scrollbar {\n  display: none;\n}\n\n/* Hide scrollbar for IE, Edge and Firefox */\n.scrollbar-hide {\n  -ms-overflow-style: none; /* IE and Edge */\n  scrollbar-width: none; /* Firefox */\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/Fullstack2Col.vue",
    "content": "<script setup lang=\"ts\">\nimport nitroIcon from '@local-assets/icons/nitro.png';\nimport metaFrameworksImage from '@local-assets/meta-frameworks.png';\nimport cloudflareLogo from '@local-assets/platforms/cloudflare.svg';\nimport netlifyLogo from '@local-assets/platforms/netlify.svg';\nimport renderLogo from '@local-assets/platforms/render.svg';\nimport vercelLogo from '@local-assets/platforms/vercel.svg';\nimport nitroTerminal from '@local-assets/terminal-features/nitro.svg';\n</script>\n\n<template>\n  <section\n    class=\"wrapper wrapper--ticks border-t grid sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-stroke\"\n  >\n    <div class=\"bg-beige/50 p-5 sm:p-10 flex flex-col gap-3 justify-between\">\n      <div class=\"flex flex-col gap-3\">\n        <h6>Meta Frameworks</h6>\n        <p class=\"max-w-[18rem] text-balance\">\n          You can use meta-frameworks that ship as Vite plugins with Vite+\n        </p>\n      </div>\n      <img loading=\"lazy\" :src=\"metaFrameworksImage\" alt=\"Meta frameworks\" class=\"w-full mt-11\" />\n    </div>\n    <div class=\"flex flex-col justify-between h-full\">\n      <div class=\"flex flex-col divide-y divide-stroke shrink-0\">\n        <div class=\"p-5 sm:p-10 flex flex-col gap-3\">\n          <h6>Platform Agnostic</h6>\n          <p class=\"max-w-[18rem] text-balance\">\n            First-class support on Vercel, Netlify, Cloudflare & more\n          </p>\n        </div>\n        <div\n          class=\"grid grid-cols-2 sm:grid-cols-4 divide-y sm:divide-y-0 divide-x divide-stroke w-full flex-grow h-30 sm:h-[4.5rem]\"\n        >\n          <!-- TODO replace with LogoGrid. Make sure mobile view is not jumpy -->\n          <div class=\"flex items-center justify-center\">\n            <img\n              loading=\"lazy\"\n              :src=\"vercelLogo\"\n              alt=\"Vercel\"\n              class=\"max-w-20 max-h-10 object-contain\"\n            />\n          </div>\n          <div class=\"flex items-center justify-center\">\n            <img\n              loading=\"lazy\"\n              :src=\"netlifyLogo\"\n              alt=\"Netlify\"\n              class=\"max-w-20 max-h-10 object-contain\"\n            />\n          </div>\n          <div class=\"flex items-center justify-center\">\n            <img\n              loading=\"lazy\"\n              :src=\"cloudflareLogo\"\n              alt=\"Cloudflare\"\n              class=\"max-w-20 max-h-10 object-contain\"\n            />\n          </div>\n          <div class=\"flex items-center justify-center\">\n            <img\n              loading=\"lazy\"\n              :src=\"renderLogo\"\n              alt=\"Render\"\n              class=\"max-w-20 max-h-10 object-contain\"\n            />\n          </div>\n        </div>\n      </div>\n      <div class=\"bg-nitro bg-right-top px-10 pt-7 flex flex-col justify-end gap-5 shrink-0\">\n        <div class=\"flex flex-row items-center gap-5\">\n          <img loading=\"lazy\" :src=\"nitroIcon\" alt=\"Nitro icon\" class=\"size-10 sm:size-12\" />\n          <h6 class=\"text-white drop-shadow-sm/70\">Deploy anywhere by pairing with Nitro</h6>\n        </div>\n        <img loading=\"lazy\" :src=\"nitroTerminal\" alt=\"Deploy anywhere with Nitro\" class=\"w-full\" />\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.bg-nitro {\n  background-image: url('@local-assets/backgrounds/nitro.jpg');\n  background-size: cover;\n  background-position: center;\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/HeadingSection2.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n  <section\n    class=\"wrapper wrapper--ticks border-t px-10 h-48 sm:h-72 flex flex-col justify-center gap-5\"\n  >\n    <h2 class=\"text-center sm:text-start\">Productivity at Enterprise Scale</h2>\n  </section>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/HeadingSection3.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n  <section class=\"wrapper px-5 sm:px-10 h-70 sm:h-80 flex flex-col justify-center gap-5\">\n    <h3>Fullstack? No problem.</h3>\n    <p class=\"max-w-md text-pretty\">\n      Vite+ can be the foundation of any type of web apps - from SPAs to fullstack meta frameworks.\n    </p>\n  </section>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/HeadingSection4.vue",
    "content": "<script setup lang=\"ts\"></script>\n\n<template>\n  <section\n    class=\"wrapper border-t px-5 sm:px-10 h-70 sm:h-[22rem] flex flex-col justify-center gap-6 text-center items-center\"\n  >\n    <h2 class=\"text-white\">Everything you need in one tool</h2>\n    <p class=\"max-w-md text-white/70 text-balance sm:text-pretty\">\n      Vite+ unifies your entire web development workflow into a single, powerful command-line\n      interface.\n    </p>\n  </section>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/Hero.vue",
    "content": "<template>\n  <div class=\"wrapper flex flex-col justify-start items-center gap-6 pt-14 pb-6\">\n    <div class=\"w-full sm:w-2xl flex flex-col justify-start items-center gap-10 px-5 sm:px-0\">\n      <div class=\"flex flex-col justify-start items-center gap-4\">\n        <img src=\"/icon.svg\" alt=\"Vite+ Logo\" class=\"w-9\" />\n        <h1 class=\"text-center text-primary text-balance shine-text\">\n          <span class=\"inline-block\">The Unified</span>\n          <span class=\"inline-block\">Toolchain for the Web</span>\n        </h1>\n        <p class=\"self-stretch text-center text-balance text-nickel\">\n          Manage your runtime, package manager, and frontend stack with one tool.\n        </p>\n        <p class=\"text-sm text-grey\">Free and open source under the MIT license.</p>\n      </div>\n      <div class=\"flex items-center gap-5\">\n        <a href=\"/guide\" target=\"_self\" class=\"button button--primary\"> Get started </a>\n        <a\n          href=\"https://voidzero.dev/posts/announcing-vite-plus-alpha\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          class=\"button\"\n        >\n          Read the Announcement\n        </a>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style scoped>\n.shine-text {\n  background: linear-gradient(\n    110deg,\n    var(--color-primary) 0%,\n    var(--color-primary) 40%,\n    #6254fe 48%,\n    #6254fe 50%,\n    #6254fe 52%,\n    var(--color-primary) 60%,\n    var(--color-primary) 100%\n  );\n  background-size: 400% 100%;\n  background-position: 100% 0;\n  background-clip: text;\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  animation: shine 5s ease-in-out 0s 1 forwards;\n}\n\n@keyframes shine {\n  to {\n    background-position: 35% 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/HeroRive.vue",
    "content": "<template>\n  <div class=\"wrapper md:border-none mt-10 md:mt-0\">\n    <RiveAnimation\n      :desktop-src=\"homepageAnimation\"\n      :mobile-src=\"homepageAnimationMobile\"\n      :desktop-width=\"1280\"\n      :desktop-height=\"580\"\n      :mobile-width=\"253\"\n      :mobile-height=\"268\"\n      canvas-class=\"w-full\"\n    />\n  </div>\n</template>\n\n<script setup lang=\"ts\">\nimport RiveAnimation from '@components/shared/RiveAnimation.vue';\nimport homepageAnimationMobile from '@local-assets/animations/253_x_268_vite+_masthead_mobile.riv';\nimport homepageAnimation from '@local-assets/animations/1280_x_580_vite+_masthead.riv';\n</script>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/InstallCommand.vue",
    "content": "<script setup lang=\"ts\">\nimport { Icon } from '@iconify/vue';\nimport { onBeforeUnmount, ref } from 'vue';\n\ntype CommandCard = {\n  id: string;\n  label: string;\n  command: string;\n};\n\nconst commandCards: CommandCard[] = [\n  {\n    id: 'unix',\n    label: 'macOS / Linux',\n    command: 'curl -fsSL https://vite.plus | bash',\n  },\n  {\n    id: 'windows',\n    label: 'Windows (PowerShell)',\n    command: 'irm https://vite.plus/ps1 | iex',\n  },\n];\n\nconst copiedId = ref<string | null>(null);\nlet copiedTimer: ReturnType<typeof setTimeout> | null = null;\n\nconst flashCopied = (id: string) => {\n  copiedId.value = id;\n  if (copiedTimer) {\n    clearTimeout(copiedTimer);\n  }\n  copiedTimer = setTimeout(() => {\n    copiedId.value = null;\n    copiedTimer = null;\n  }, 1600);\n};\n\nconst copyCommand = async (id: string, command: string) => {\n  try {\n    await navigator.clipboard.writeText(command);\n    flashCopied(id);\n  } catch {}\n};\n\nonBeforeUnmount(() => {\n  if (copiedTimer) {\n    clearTimeout(copiedTimer);\n  }\n});\n</script>\n\n<template>\n  <section\n    class=\"wrapper border-t grid lg:grid-cols-[0.9fr_1.1fr] divide-y lg:divide-y-0 lg:divide-x\"\n  >\n    <div class=\"px-5 py-6 sm:p-10 flex flex-col gap-4 justify-center\">\n      <span class=\"text-grey text-xs font-mono uppercase tracking-wide\">Getting started</span>\n      <h4>Install vp globally</h4>\n      <p class=\"max-w-[28rem] text-pretty\">\n        Install Vite+ once, open a new terminal session, then run <code>vp help</code>.\n      </p>\n      <p class=\"text-sm text-grey\">\n        For CI, use\n        <a\n          class=\"text-primary underline decoration-stroke underline-offset-4\"\n          href=\"https://github.com/voidzero-dev/setup-vp\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          setup-vp\n        </a>\n        .\n      </p>\n    </div>\n    <div class=\"px-5 py-6 sm:p-10 grid gap-4\">\n      <div\n        v-for=\"card in commandCards\"\n        :key=\"card.id\"\n        class=\"rounded-xl bg-primary text-white p-5 outline outline-white/10 transition-colors hover:bg-[#1a1a1a]\"\n      >\n        <div class=\"flex items-start justify-between gap-4\">\n          <div class=\"min-w-0 flex-1\">\n            <div class=\"text-grey text-xs font-mono uppercase tracking-wide\">{{ card.label }}</div>\n            <div\n              class=\"mt-3 block overflow-x-auto whitespace-nowrap rounded-md bg-transparent p-0 font-mono text-white outline-none\"\n            >\n              {{ card.command }}\n            </div>\n          </div>\n          <button\n            type=\"button\"\n            class=\"shrink-0 inline-flex items-center gap-2 rounded-md border border-white/12 px-3 py-2 text-sm text-grey transition-colors hover:text-white hover:border-white/25\"\n            :aria-label=\"`Copy ${card.label} install command`\"\n            @click=\"copyCommand(card.id, card.command)\"\n          >\n            <Icon\n              :icon=\"copiedId === card.id ? 'lucide:check' : 'lucide:copy'\"\n              class=\"size-4\"\n              aria-hidden=\"true\"\n            />\n            <span>{{ copiedId === card.id ? 'Copied' : 'Copy' }}</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/PartnerLogos.vue",
    "content": "<script setup lang=\"ts\">\nimport boltLogo from '@assets/clients/bolt.svg';\nimport cloudflareLogo from '@assets/clients/cloudflare.svg';\nimport framerLogo from '@assets/clients/framer.svg';\nimport huggingfaceLogo from '@assets/clients/hugging-face.svg';\nimport linearLogo from '@assets/clients/linear.svg';\nimport mercedesLogo from '@assets/clients/mercedes.svg';\nimport openaiLogo from '@assets/clients/openai.svg';\n// Import client logos\nimport shopifyLogo from '@assets/clients/shopify.svg';\n\nconst logos = [\n  { src: openaiLogo, alt: 'OpenAI' },\n  { src: boltLogo, alt: 'Bolt' },\n  { src: framerLogo, alt: 'Framer' },\n  { src: linearLogo, alt: 'Linear' },\n  { src: shopifyLogo, alt: 'Shopify' },\n  { src: mercedesLogo, alt: 'Mercedes' },\n  { src: huggingfaceLogo, alt: 'Hugging Face' },\n  { src: cloudflareLogo, alt: 'Cloudflare' },\n];\n</script>\n\n<template>\n  <section\n    class=\"wrapper wrapper--ticks md:pl-10 flex flex-col md:flex-row items-center border-t md:border-t-0\"\n  >\n    <div class=\"w-full md:flex-1 py-6 border-b md:border-b-0 md:border-r text-center md:text-start\">\n      <p class=\"text-nickel\">Trusted by the world's best software teams</p>\n    </div>\n    <div class=\"flex-1 py-6 overflow-hidden\">\n      <div class=\"scroll-container\">\n        <div class=\"scroll-content\">\n          <div class=\"flex gap-20 items-center\">\n            <div v-for=\"logo in logos\" :key=\"logo.alt\" class=\"logo-container\">\n              <img loading=\"lazy\" :src=\"logo.src\" :alt=\"logo.alt\" class=\"grayscale\" />\n            </div>\n          </div>\n          <div class=\"flex gap-20 items-center\">\n            <div v-for=\"logo in logos\" :key=\"`${logo.alt}-duplicate`\" class=\"logo-container\">\n              <img loading=\"lazy\" :src=\"logo.src\" :alt=\"logo.alt\" />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n.scroll-container {\n  position: relative;\n}\n\n.scroll-content {\n  display: flex;\n  gap: 80px;\n  animation: scroll-horizontal 20s linear infinite;\n  width: fit-content;\n}\n\n.scroll-content > div {\n  flex-shrink: 0;\n}\n\n.logo-container {\n  width: 88px;\n  height: 25px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  position: relative;\n}\n\n.scroll-content > div {\n  flex-shrink: 0;\n  position: relative;\n}\n\n.logo-container::after {\n  content: '';\n  position: absolute;\n  right: -40px;\n  top: -1.5rem;\n  bottom: -1.5rem;\n  width: 1px;\n  background-color: var(--color-stroke);\n}\n\n.logo-container img {\n  max-width: 100%;\n  max-height: 100%;\n  object-fit: contain;\n}\n\n@keyframes scroll-horizontal {\n  0% {\n    transform: translateX(0);\n  }\n  100% {\n    transform: translateX(calc(-88px * 7 - 80px * 6 - 80px));\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/ProductivityGrid.vue",
    "content": "<script setup lang=\"ts\">\nimport RiveAnimation from '@components/shared/RiveAnimation.vue';\nimport focusOnShippingAnimation from '@local-assets/animations/514_x_246_focus_on_shipping_v2.riv';\nimport stayFastAtScaleAnimation from '@local-assets/animations/561_x_273_stay_fast_at_scale.riv';\nimport productivitySecurityImage from '@local-assets/productivity-security.png';\nimport tileOxc from '@local-assets/tiles/oxc.png';\nimport tileVite from '@local-assets/tiles/vite.png';\nimport tileVitest from '@local-assets/tiles/vitest.png';\n</script>\n\n<template>\n  <section\n    class=\"wrapper wrapper--ticks border-t grid lg:grid-cols-2 divide-x divide-y divide-stroke\"\n  >\n    <div class=\"p-5 sm:p-10 flex flex-col gap-3\">\n      <h5 class=\"text-balance sm:text-pretty\">A trusted stack to standardize on</h5>\n      <p class=\"sm:max-w-[28rem] text-pretty mb-12 sm:mb-16\">\n        Vite+ is built on established open source industry standards, and maintained by the same\n        experts behind these projects.\n      </p>\n      <div class=\"flex flex-col gap-6 sm:pr-10 -mx-5 sm:mx-0 divide-y sm:divide-y-0 divide-stroke\">\n        <div class=\"flex flex-col gap-6 pt-6 pb-6 sm:pb-0 px-5 sm:px-0 sm:pt-0 first:pt-0\">\n          <img loading=\"lazy\" :src=\"tileVite\" alt=\"Vite\" class=\"w-24 flex-shrink-0 sm:hidden\" />\n          <div class=\"flex flex-row sm:items-center gap-6\">\n            <img\n              loading=\"lazy\"\n              :src=\"tileVite\"\n              alt=\"Vite\"\n              class=\"w-24 flex-shrink-0 hidden sm:block\"\n            />\n            <div\n              class=\"relative flex flex-col flex-1 sm:pl-6 before:content-none sm:before:content-[''] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-full before:w-px before:bg-stroke\"\n            >\n              <p class=\"text-primary font-medium text-base sm:text-xl\">69m+</p>\n              <p class=\"leading-tight text-base\">Weekly npm downloads</p>\n            </div>\n            <div\n              class=\"relative flex flex-col flex-shrink-0 pl-6 before:content-[''] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-full before:w-px before:bg-stroke\"\n            >\n              <p class=\"text-primary font-medium text-base sm:text-xl\">78.7k</p>\n              <p class=\"leading-tight text-base\">GitHub stars</p>\n            </div>\n          </div>\n        </div>\n        <div class=\"flex flex-col gap-6 pt-6 pb-6 sm:pb-0 px-5 sm:px-0 sm:pt-0 first:pt-0\">\n          <img loading=\"lazy\" :src=\"tileVitest\" alt=\"Vitest\" class=\"w-24 flex-shrink-0 sm:hidden\" />\n          <div class=\"flex flex-row sm:items-center gap-6\">\n            <img\n              loading=\"lazy\"\n              :src=\"tileVitest\"\n              alt=\"Vitest\"\n              class=\"w-24 flex-shrink-0 hidden sm:block\"\n            />\n            <div\n              class=\"relative flex flex-col flex-1 sm:pl-6 before:content-none sm:before:content-[''] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-full before:w-px before:bg-stroke\"\n            >\n              <p class=\"text-primary font-medium text-base sm:text-xl\">35m+</p>\n              <p class=\"leading-tight text-base\">Weekly npm downloads</p>\n            </div>\n            <div\n              class=\"relative flex flex-col flex-shrink-0 pl-6 before:content-[''] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-full before:w-px before:bg-stroke\"\n            >\n              <p class=\"text-primary font-medium text-base sm:text-xl\">16.1k</p>\n              <p class=\"leading-tight text-base\">GitHub stars</p>\n            </div>\n          </div>\n        </div>\n        <div class=\"flex flex-col gap-6 pt-6 pb-6 sm:pb-0 px-5 sm:px-0 sm:pt-0 first:pt-0\">\n          <img loading=\"lazy\" :src=\"tileOxc\" alt=\"Oxc\" class=\"w-24 flex-shrink-0 sm:hidden\" />\n          <div class=\"flex flex-row sm:items-center gap-6\">\n            <img\n              loading=\"lazy\"\n              :src=\"tileOxc\"\n              alt=\"Oxc\"\n              class=\"w-24 flex-shrink-0 hidden sm:block\"\n            />\n            <div\n              class=\"relative flex flex-col flex-1 sm:pl-6 before:content-none sm:before:content-[''] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-full before:w-px before:bg-stroke\"\n            >\n              <p class=\"text-primary font-medium text-base sm:text-xl\">5m+</p>\n              <p class=\"leading-tight text-base\">Weekly npm downloads</p>\n            </div>\n            <div\n              class=\"relative flex flex-col flex-shrink-0 pl-6 before:content-[''] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-full before:w-px before:bg-stroke\"\n            >\n              <p class=\"text-primary font-medium text-base sm:text-xl\">19.8k</p>\n              <p class=\"leading-tight text-base\">GitHub stars</p>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"p-5 sm:p-10 flex flex-col gap-3 border-r-0\">\n      <h5>Stay fast at scale</h5>\n      <p class=\"max-w-[26rem] text-pretty\">\n        With low-level components written in Rust, Vite+ delivers enterprise-scale performance: up\n        to <span class=\"text-primary\">40× faster builds</span> than webpack,\n        <span class=\"text-primary\">~50× to ~100× faster linting</span> than ESLint, and\n        <span class=\"text-primary\">up to 30× faster formatting</span> than Prettier.\n      </p>\n      <RiveAnimation\n        :desktop-src=\"stayFastAtScaleAnimation\"\n        :desktop-width=\"561\"\n        :desktop-height=\"273\"\n        canvas-class=\"w-full mt-10\"\n      />\n    </div>\n    <div class=\"p-5 sm:p-10 flex flex-col gap-3\">\n      <h5>Focus on shipping, not tooling</h5>\n      <ul class=\"list-disc pl-3 flex flex-col gap-2 marker:text-ruby mt-1\">\n        <li class=\"leading-tight\">Stop wasting time on tooling maintenance</li>\n        <li class=\"leading-tight\">Improve cross-team developer mobility</li>\n        <li class=\"leading-tight\">\n          Standardize best practices for humans and AI-assisted workflows\n        </li>\n      </ul>\n      <RiveAnimation\n        :desktop-src=\"focusOnShippingAnimation\"\n        :desktop-width=\"514\"\n        :desktop-height=\"246\"\n        canvas-class=\"w-full mt-10\"\n      />\n    </div>\n    <div class=\"p-5 sm:p-10 flex flex-col gap-3\">\n      <h5>Supply chain security</h5>\n      <p class=\"max-w-[20rem] text-pretty\">\n        Vite+ development follows rigorous security practices, and we vet its dependencies across\n        the unified toolchain.\n      </p>\n      <img\n        loading=\"lazy\"\n        class=\"w-full\"\n        :src=\"productivitySecurityImage\"\n        alt=\"Vite+ vets all dependencies with rigorous security practices\"\n      />\n    </div>\n  </section>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/StackedBlock.vue",
    "content": "<script setup lang=\"ts\">\ndefineProps<{\n  src: string;\n  alt: string;\n  href?: string;\n}>();\n\nimport {\n  TooltipArrow,\n  TooltipContent,\n  TooltipPortal,\n  TooltipProvider,\n  TooltipRoot,\n  TooltipTrigger,\n} from 'reka-ui';\n</script>\n\n<template>\n  <TooltipProvider :delayDuration=\"0\">\n    <TooltipRoot>\n      <TooltipTrigger as-child>\n        <li v-if=\"!href\">\n          <img loading=\"lazy\" :src=\"src\" :alt=\"alt\" />\n        </li>\n      </TooltipTrigger>\n      <TooltipTrigger as-child v-if=\"href\">\n        <a :href=\"href\" target=\"_blank\" rel=\"noopener noreferrer\">\n          <img loading=\"lazy\" :src=\"src\" :alt=\"alt\" />\n        </a>\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent\n          class=\"data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade select-none font-mono text-space text-sm px-1.5 py-0.5 outline outline-stroke rounded will-change-[transform,opacity]\"\n          :side-offset=\"5\"\n        >\n          {{ alt }}\n          <TooltipArrow class=\"fill-white stroke-stroke\" :width=\"12\" :height=\"6\" />\n        </TooltipContent>\n      </TooltipPortal>\n    </TooltipRoot>\n  </TooltipProvider>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/Terminal.vue",
    "content": "<script setup lang=\"ts\">\nimport { TabsList, TabsRoot, TabsTrigger } from 'reka-ui';\nimport { computed, onMounted, onUnmounted, ref } from 'vue';\n\nimport { terminalTranscripts } from '../../data/terminal-transcripts';\nimport TerminalTranscript from './TerminalTranscript.vue';\n\n// Auto-progression configuration\nconst AUTO_ADVANCE_DELAY = 1500;\n\n// State management\nconst activeTab = ref(terminalTranscripts[0].id);\nconst autoPlayEnabled = ref(true);\nlet autoAdvanceTimeout: ReturnType<typeof setTimeout> | null = null;\n\n// Intersection Observer state\nconst sectionRef = ref<HTMLElement | null>(null);\nconst isVisible = ref(false);\nlet observer: IntersectionObserver | null = null;\n\n// Tab progression logic\nconst tabSequence = terminalTranscripts.map((transcript) => transcript.id);\n\nconst activeTranscript = computed(\n  () =>\n    terminalTranscripts.find((transcript) => transcript.id === activeTab.value) ??\n    terminalTranscripts[0],\n);\n\nconst goToNextTab = () => {\n  const currentIndex = tabSequence.indexOf(activeTab.value);\n  const nextIndex = (currentIndex + 1) % tabSequence.length;\n  activeTab.value = tabSequence[nextIndex];\n};\n\n// Handle animation completion\nconst onAnimationComplete = () => {\n  if (!autoPlayEnabled.value) {\n    return;\n  }\n\n  // Clear any existing timeout\n  if (autoAdvanceTimeout) {\n    clearTimeout(autoAdvanceTimeout);\n  }\n\n  // Schedule next tab\n  autoAdvanceTimeout = setTimeout(() => {\n    goToNextTab();\n  }, AUTO_ADVANCE_DELAY);\n};\n\n// Handle user interaction with tabs\nconst onTabChange = () => {\n  // User clicked a tab, disable auto-play\n  autoPlayEnabled.value = false;\n\n  // Clear any pending auto-advance\n  if (autoAdvanceTimeout) {\n    clearTimeout(autoAdvanceTimeout);\n    autoAdvanceTimeout = null;\n  }\n};\n\n// Setup Intersection Observer\nonMounted(() => {\n  if (!sectionRef.value) {\n    return;\n  }\n\n  observer = new IntersectionObserver(\n    (entries) => {\n      entries.forEach((entry) => {\n        if (entry.isIntersecting && !isVisible.value) {\n          isVisible.value = true;\n          // Disconnect observer after first intersection\n          observer?.disconnect();\n        }\n      });\n    },\n    {\n      threshold: 0.2, // Trigger when 20% of the element is visible\n      rootMargin: '0px',\n    },\n  );\n\n  observer.observe(sectionRef.value);\n});\n\n// Cleanup\nonUnmounted(() => {\n  if (autoAdvanceTimeout) {\n    clearTimeout(autoAdvanceTimeout);\n  }\n  if (observer) {\n    observer.disconnect();\n  }\n});\n</script>\n\n<template>\n  <section\n    ref=\"sectionRef\"\n    class=\"wrapper border-t h-[40rem] bg-wine terminal-background bg-cover bg-top flex justify-center pt-28 overflow-clip\"\n  >\n    <div\n      :class=\"[\n        'self-stretch px-4 sm:px-8 py-5 sm:py-7 relative bg-[#111] rounded-tl-lg rounded-tr-lg inline-flex flex-col justify-start items-start gap-2 overflow-hidden w-[62rem] outline-1 outline-offset-[3px] outline-white/30',\n        'transition-transform duration-1000',\n        isVisible ? 'translate-y-0' : 'translate-y-24',\n      ]\"\n      style=\"transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1)\"\n    >\n      <TabsRoot v-if=\"isVisible\" v-model=\"activeTab\" @update:modelValue=\"onTabChange\">\n        <div class=\"w-full\">\n          <TerminalTranscript\n            :key=\"activeTranscript.id\"\n            :transcript=\"activeTranscript\"\n            @complete=\"onAnimationComplete\"\n          />\n        </div>\n        <TabsList\n          aria-label=\"features\"\n          :class=\"[\n            'absolute bottom-6 left-1/2 -translate-x-1/2 flex items-center p-1 rounded-md border border-white/10',\n            'transition-transform duration-700 delay-300',\n            isVisible ? 'translate-y-0' : 'translate-y-12',\n          ]\"\n          style=\"transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1)\"\n        >\n          <TabsTrigger\n            v-for=\"transcript in terminalTranscripts\"\n            :key=\"transcript.id\"\n            :value=\"transcript.id\"\n          >\n            {{ transcript.label }}\n          </TabsTrigger>\n        </TabsList>\n      </TabsRoot>\n    </div>\n  </section>\n</template>\n\n<style scoped></style>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/TerminalTranscript.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, ref, watch } from 'vue';\n\nimport type {\n  TerminalLine,\n  TerminalSegment,\n  TerminalTone,\n  TerminalTranscript,\n} from '../../data/terminal-transcripts';\n\nconst props = defineProps<{\n  transcript: TerminalTranscript;\n  animate?: boolean;\n}>();\n\nconst emit = defineEmits<{\n  complete: [];\n}>();\n\nconst visibleLineCount = ref(0);\nconst renderedPrompt = ref('');\nconst promptFinished = ref(false);\nlet timers: number[] = [];\n\nconst promptText = computed(() => `${props.transcript.prompt ?? '$'} ${props.transcript.command}`);\n\nconst visibleLines = computed(() => props.transcript.lines.slice(0, visibleLineCount.value));\n\nconst clearTimers = () => {\n  timers.forEach((timer) => window.clearTimeout(timer));\n  timers = [];\n};\n\nconst restartAnimation = () => {\n  clearTimers();\n  if (props.animate === false) {\n    visibleLineCount.value = props.transcript.lines.length;\n    renderedPrompt.value = promptText.value;\n    promptFinished.value = true;\n    return;\n  }\n\n  visibleLineCount.value = 0;\n  renderedPrompt.value = '';\n  promptFinished.value = false;\n\n  const characterDelay = 18;\n  Array.from(promptText.value).forEach((character, index) => {\n    const timer = window.setTimeout(() => {\n      renderedPrompt.value += character;\n      if (index === promptText.value.length - 1) {\n        promptFinished.value = true;\n        props.transcript.lines.forEach((_, lineIndex) => {\n          const revealTimer = window.setTimeout(\n            () => {\n              visibleLineCount.value = lineIndex + 1;\n              if (lineIndex === props.transcript.lines.length - 1) {\n                const completionTimer = window.setTimeout(\n                  () => emit('complete'),\n                  props.transcript.completionDelay ?? 900,\n                );\n                timers.push(completionTimer);\n              }\n            },\n            (props.transcript.lineDelay ?? 220) * (lineIndex + 1),\n          );\n          timers.push(revealTimer);\n        });\n      }\n    }, characterDelay * index);\n    timers.push(timer);\n  });\n};\n\nconst lineClass = (line: TerminalLine) => toneClass(line.tone ?? 'base');\nconst segmentClass = (segment: TerminalSegment) => [\n  toneClass(segment.tone ?? 'base'),\n  segment.bold ? 'font-bold' : '',\n];\n\nconst toneClass = (tone: TerminalTone) => {\n  switch (tone) {\n    case 'muted':\n      return 'terminal-tone-muted';\n    case 'brand':\n      return 'terminal-tone-brand';\n    case 'accent':\n      return 'terminal-tone-accent';\n    case 'success':\n      return 'terminal-tone-success';\n    case 'warning':\n      return 'terminal-tone-warning';\n    default:\n      return 'terminal-tone-base';\n  }\n};\n\nwatch(\n  () => [props.transcript.id, props.animate],\n  () => restartAnimation(),\n  { immediate: true },\n);\n\nonBeforeUnmount(() => clearTimers());\n</script>\n\n<template>\n  <div class=\"terminal-copy\">\n    <div class=\"terminal-prompt\">\n      <span class=\"terminal-tone-base\">{{ renderedPrompt }}</span>\n      <span v-if=\"!promptFinished\" class=\"terminal-cursor\" aria-hidden=\"true\" />\n    </div>\n    <div class=\"terminal-spacer\" />\n    <TransitionGroup name=\"terminal-line\">\n      <div\n        v-for=\"(line, index) in visibleLines\"\n        :key=\"`${transcript.id}-${index}`\"\n        class=\"terminal-line\"\n        :class=\"lineClass(line)\"\n      >\n        <template\n          v-for=\"(segment, segmentIndex) in line.segments\"\n          :key=\"`${transcript.id}-${index}-${segmentIndex}`\"\n        >\n          <span :class=\"segmentClass(segment)\">{{ segment.text }}</span>\n        </template>\n      </div>\n    </TransitionGroup>\n  </div>\n</template>\n"
  },
  {
    "path": "docs/.vitepress/theme/components/home/Testimonials.vue",
    "content": "<script setup lang=\"ts\">\nimport 'vue3-carousel/carousel.css';\nimport { Carousel, Slide, Pagination, Navigation } from 'vue3-carousel';\n\nimport { testimonials } from '../../data/testimonials';\n\nconst carouselConfig = {\n  itemsToShow: 'auto',\n  wrapAround: true,\n  snapAlign: 'start',\n};\n</script>\n\n<template>\n  <section\n    class=\"wrapper border-t wrapper--ticks grid grid-cols-1 md:grid-cols-3 divide-x-0 md:divide-x divide-stroke h-auto md:h-[32.5rem]\"\n  >\n    <div class=\"flex flex-col gap-0 md:gap-3 divide-y divide-stroke\">\n      <div\n        class=\"p-5 md:p-10 grow-0 md:grow flex flex-col gap-5 h-72 justify-center md:justify-start\"\n      >\n        <h3 class=\"text-pretty\">Hear from our customers</h3>\n        <p class=\"block md:hidden\">\n          LLMs already know Vite. Leading prompt-to-app platforms including Bolt, Lovable and Replit\n          all default to building user applications using Vite.\n        </p>\n      </div>\n      <div class=\"px-5 py-6 md:px-10 md:pt-7 md:pb-8 flex flex-col gap-4\">\n        <h3 class=\"text-4xl sm:text-5xl font-normal font-sans\">500k+</h3>\n        <p>Stat label goes here</p>\n      </div>\n    </div>\n    <div class=\"col-span-1 md:col-span-2 bg-beige/50 border-t border-stroke md:border-t-0\">\n      <Carousel v-bind=\"carouselConfig\" class=\"py-10 pb-20 sm:pb-10\">\n        <Slide v-for=\"(testimonial, index) in testimonials\" :key=\"index\">\n          <div\n            class=\"bg-white outline-1 outline-stroke p-5 md:p-10 my-1 ml-5 md:ml-10 rounded w-[80vw] md:w-[25rem] lg:w-[35rem] translate-x-[-1px] flex flex-col justify-between min-h-[60vh] md:min-h-0 h-auto md:h-[23rem]\"\n          >\n            <div class=\"flex flex-col justify-start items-start gap-10\">\n              <img\n                loading=\"lazy\"\n                :src=\"testimonial.logo\"\n                :alt=\"testimonial.logoAlt\"\n                class=\"max-w-20 max-h-10 object-contain\"\n              />\n              <span class=\"text-2xl text-primary font-sans leading-[1.7rem]\">\n                \"{{ testimonial.quote }}\"\n              </span>\n            </div>\n            <div class=\"flex items-center gap-4\">\n              <img\n                loading=\"lazy\"\n                :src=\"testimonial.image\"\n                :alt=\"`${testimonial.name} at ${testimonial.company}`\"\n                class=\"size-12\"\n              />\n              <p class=\"text-grey text-sm font-sans leading-tight\">\n                {{ testimonial.name }}<br />{{ testimonial.title }} @ {{ testimonial.company }}\n              </p>\n            </div>\n          </div>\n        </Slide>\n        <template #addons>\n          <Pagination />\n        </template>\n      </Carousel>\n    </div>\n  </section>\n</template>\n\n<style>\n.carousel__pagination {\n}\n\n.carousel__pagination-button {\n  height: 6px;\n  width: 6px;\n  border-radius: 2px;\n  background-color: var(--color-stroke);\n}\n.carousel__pagination-button--active {\n  background-color: var(--color-primary);\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/data/feature-run-transcripts.ts",
    "content": "import type { TerminalTranscript } from './terminal-transcripts';\n\nexport const featureRunTranscripts: TerminalTranscript[] = [\n  {\n    id: 'cold',\n    label: 'Cold Cache',\n    title: 'First run builds the shared library and app',\n    command: 'vp run --cache build',\n    lineDelay: 180,\n    completionDelay: 1200,\n    lines: [\n      {\n        segments: [{ text: '# First run builds the shared library and app', tone: 'muted' }],\n      },\n      {\n        segments: [{ text: '$ vp pack', tone: 'muted' }],\n      },\n      {\n        segments: [{ text: '$ vp build', tone: 'muted' }],\n      },\n      {\n        segments: [\n          { text: 'vp run:', tone: 'brand', bold: true },\n          { text: ' 0/2 cache hit (0%).', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'no-changes',\n    label: 'Full Replay',\n    title: 'No changes replay both tasks from cache',\n    command: 'vp run --cache build',\n    lineDelay: 180,\n    completionDelay: 1200,\n    lines: [\n      {\n        segments: [{ text: '# No changes replay both tasks from cache', tone: 'muted' }],\n      },\n      {\n        segments: [\n          { text: '$ vp pack ', tone: 'muted' },\n          { text: '✓ ', tone: 'success' },\n          { text: 'cache hit, replaying', tone: 'base' },\n        ],\n      },\n      {\n        segments: [\n          { text: '$ vp build ', tone: 'muted' },\n          { text: '✓ ', tone: 'success' },\n          { text: 'cache hit, replaying', tone: 'base' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'vp run:', tone: 'brand', bold: true },\n          { text: ' 2/2 cache hit (100%), 1.24s saved.', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'app-change',\n    label: 'Partial Replay',\n    title: 'App changes rerun only the app build',\n    command: 'vp run --cache build',\n    lineDelay: 180,\n    completionDelay: 1200,\n    lines: [\n      {\n        segments: [{ text: '# App changes rerun only the app build', tone: 'muted' }],\n      },\n      {\n        segments: [\n          { text: '$ vp pack ', tone: 'muted' },\n          { text: '✓ ', tone: 'success' },\n          { text: 'cache hit, replaying', tone: 'base' },\n        ],\n      },\n      {\n        segments: [\n          { text: '$ vp build ', tone: 'muted' },\n          { text: '✗ ', tone: 'base' },\n          { text: 'cache miss: ', tone: 'muted' },\n          { text: \"'src/main.ts'\", tone: 'base' },\n          { text: ' modified, executing', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'vp run:', tone: 'brand', bold: true },\n          { text: ' 1/2 cache hit (50%), 528ms saved.', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'shared-change',\n    label: 'Full Rebuild',\n    title: 'Shared API changes rebuild the library and app',\n    command: 'vp run --cache build',\n    lineDelay: 180,\n    completionDelay: 1200,\n    lines: [\n      {\n        segments: [{ text: '# Shared API changes rebuild the library and app', tone: 'muted' }],\n      },\n      {\n        segments: [\n          { text: '$ vp pack ', tone: 'muted' },\n          { text: '✗ ', tone: 'base' },\n          { text: 'cache miss: ', tone: 'muted' },\n          { text: \"'src/index.ts'\", tone: 'base' },\n          { text: ' modified, executing', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: '$ vp build ', tone: 'muted' },\n          { text: '✗ ', tone: 'base' },\n          { text: 'cache miss: ', tone: 'muted' },\n          { text: \"'src/routes.ts'\", tone: 'base' },\n          { text: ' modified, executing', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'vp run:', tone: 'brand', bold: true },\n          { text: ' 0/2 cache hit (0%).', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n];\n"
  },
  {
    "path": "docs/.vitepress/theme/data/performance.ts",
    "content": "export interface PerformanceData {\n  name: string;\n  percentage: number;\n  time: string;\n  isPrimary?: boolean;\n}\n\nexport const devPerformance: PerformanceData[] = [\n  {\n    name: 'Vite Dev',\n    percentage: 15,\n    time: '102MS',\n    isPrimary: true,\n  },\n  {\n    name: 'Webpack',\n    percentage: 50,\n    time: '2.38S',\n  },\n  {\n    name: 'Rspack',\n    percentage: 60,\n    time: '2.38S',\n  },\n  {\n    name: 'Vite 7',\n    percentage: 90,\n    time: '2.38S',\n  },\n  {\n    name: 'NextJS',\n    percentage: 100,\n    time: '2.38S',\n  },\n];\n\nexport const buildPerformance: PerformanceData[] = [\n  {\n    name: 'Vite Build',\n    percentage: 20,\n    time: '1.2S',\n    isPrimary: true,\n  },\n  {\n    name: 'Webpack',\n    percentage: 75,\n    time: '8.4S',\n  },\n  {\n    name: 'Rspack',\n    percentage: 45,\n    time: '3.1S',\n  },\n  {\n    name: 'Vite 7',\n    percentage: 85,\n    time: '7.2S',\n  },\n  {\n    name: 'NextJS',\n    percentage: 100,\n    time: '9.8S',\n  },\n];\n\nexport const testPerformance: PerformanceData[] = [\n  {\n    name: 'Vite Test',\n    percentage: 15,\n    time: '102MS',\n    isPrimary: true,\n  },\n  {\n    name: 'Jest+SWC',\n    percentage: 45,\n    time: '2.38S',\n  },\n  {\n    name: 'Jest+TS-Jest',\n    percentage: 55,\n    time: '2.38S',\n  },\n  {\n    name: 'Jest+Babel',\n    percentage: 75,\n    time: '2.38S',\n  },\n];\n\nexport const lintSyntaticPerformance: PerformanceData[] = [\n  {\n    name: 'Syntatic Mode',\n    percentage: 10,\n    time: '102MS',\n    isPrimary: true,\n  },\n  {\n    name: 'ESLint',\n    percentage: 50,\n    time: '2.38S',\n  },\n  {\n    name: 'Biome',\n    percentage: 45,\n    time: '2.38S',\n  },\n];\n\nexport const lintTypeAwarePerformance: PerformanceData[] = [\n  {\n    name: 'Type-Aware Mode',\n    percentage: 25,\n    time: '380MS',\n    isPrimary: true,\n  },\n  {\n    name: 'ESLint',\n    percentage: 85,\n    time: '4.2S',\n  },\n  {\n    name: 'TypeScript',\n    percentage: 70,\n    time: '3.1S',\n  },\n  {\n    name: 'Biome',\n    percentage: 60,\n    time: '2.8S',\n  },\n];\n\nexport const formatPerformance: PerformanceData[] = [\n  {\n    name: 'Vite Format',\n    percentage: 10,\n    time: '102MS',\n    isPrimary: true,\n  },\n  {\n    name: 'ESLint',\n    percentage: 50,\n    time: '2.38S',\n  },\n  {\n    name: 'Biome',\n    percentage: 45,\n    time: '2.38S',\n  },\n];\n"
  },
  {
    "path": "docs/.vitepress/theme/data/terminal-transcripts.ts",
    "content": "export type TerminalTone = 'base' | 'muted' | 'brand' | 'accent' | 'success' | 'warning';\n\nexport interface TerminalSegment {\n  text: string;\n  tone?: TerminalTone;\n  bold?: boolean;\n}\n\nexport interface TerminalLine {\n  segments: TerminalSegment[];\n  tone?: TerminalTone;\n}\n\nexport interface TerminalTranscript {\n  id: string;\n  label: string;\n  title: string;\n  command: string;\n  prompt?: string;\n  lineDelay?: number;\n  completionDelay?: number;\n  lines: TerminalLine[];\n}\n\nexport const terminalTranscripts: TerminalTranscript[] = [\n  {\n    id: 'create',\n    label: 'create',\n    title: 'Scaffold a project',\n    command: 'vp create',\n    lineDelay: 220,\n    completionDelay: 900,\n    lines: [\n      {\n        segments: [\n          { text: '◇ ', tone: 'accent' },\n          { text: 'Select a template ', tone: 'muted' },\n          { text: 'vite:application', tone: 'brand' },\n        ],\n      },\n      {\n        segments: [\n          { text: '◇ ', tone: 'accent' },\n          { text: 'Project directory ', tone: 'muted' },\n          { text: 'vite-app', tone: 'brand' },\n        ],\n      },\n      {\n        segments: [\n          { text: '• ', tone: 'muted' },\n          { text: 'Node ', tone: 'muted' },\n          { text: '24.14.0', tone: 'brand' },\n          { text: '  pnpm ', tone: 'muted' },\n          { text: '10.28.0', tone: 'accent' },\n        ],\n      },\n      {\n        segments: [\n          { text: '✓ ', tone: 'success' },\n          { text: 'Dependencies installed', tone: 'base' },\n          { text: ' in 1.1s', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: '→ ', tone: 'brand' },\n          { text: 'Next: ', tone: 'muted' },\n          { text: 'cd vite-app && vp dev', tone: 'accent' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'dev',\n    label: 'dev',\n    title: 'Start local development',\n    command: 'vp dev',\n    lineDelay: 220,\n    completionDelay: 1100,\n    lines: [\n      {\n        segments: [\n          { text: 'VITE+ ', tone: 'brand' },\n          { text: 'ready in ', tone: 'muted' },\n          { text: '68ms', tone: 'base' },\n        ],\n      },\n      {\n        segments: [\n          { text: '→ ', tone: 'brand' },\n          { text: 'Local ', tone: 'muted' },\n          { text: 'http://localhost:5173/', tone: 'accent' },\n        ],\n      },\n      {\n        segments: [\n          { text: '→ ', tone: 'muted' },\n          { text: 'Network ', tone: 'muted' },\n          { text: '--host', tone: 'base' },\n          { text: ' to expose', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: '[hmr] ', tone: 'accent' },\n          { text: 'updated ', tone: 'muted' },\n          { text: 'src/App.tsx', tone: 'brand' },\n          { text: ' in 14ms', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'check',\n    label: 'check',\n    title: 'Check the whole project',\n    command: 'vp check',\n    lineDelay: 220,\n    completionDelay: 1100,\n    lines: [\n      {\n        segments: [\n          { text: 'pass: ', tone: 'accent' },\n          { text: 'All 42 files are correctly formatted', tone: 'base' },\n          { text: ' (88ms, 16 threads)', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'pass: ', tone: 'accent' },\n          { text: 'Found no warnings, lint errors, or type errors', tone: 'base' },\n          { text: ' in 42 files', tone: 'muted' },\n          { text: ' (184ms, 16 threads)', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'test',\n    label: 'test',\n    title: 'Run tests with fast feedback',\n    command: 'vp test',\n    lineDelay: 220,\n    completionDelay: 1100,\n    lines: [\n      {\n        segments: [\n          { text: 'RUN ', tone: 'muted' },\n          { text: 'test/button.spec.ts', tone: 'brand' },\n          { text: ' (3 tests)', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: '✓ ', tone: 'success' },\n          { text: 'button renders loading state', tone: 'base' },\n        ],\n      },\n      {\n        segments: [\n          { text: '✓ ', tone: 'success' },\n          { text: '12 tests passed', tone: 'base' },\n          { text: ' across 4 files', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'Duration ', tone: 'muted' },\n          { text: '312ms', tone: 'accent' },\n          { text: ' (transform 22ms, tests 31ms)', tone: 'muted' },\n        ],\n      },\n    ],\n  },\n  {\n    id: 'build',\n    label: 'build',\n    title: 'Ship a production build',\n    command: 'vp build',\n    lineDelay: 220,\n    completionDelay: 1100,\n    lines: [\n      {\n        segments: [\n          { text: 'Rolldown ', tone: 'brand' },\n          { text: 'building for production', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: '✓ ', tone: 'success' },\n          { text: '128 modules transformed', tone: 'base' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'dist/assets/index-B6h2Q8.js', tone: 'accent' },\n          { text: '  46.2 kB  gzip: 14.9 kB', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: 'dist/assets/index-H3a8K2.css', tone: 'brand' },\n          { text: '  5.1 kB  gzip: 1.6 kB', tone: 'muted' },\n        ],\n      },\n      {\n        segments: [\n          { text: '✓ ', tone: 'success' },\n          { text: 'Built in ', tone: 'muted' },\n          { text: '421ms', tone: 'base' },\n        ],\n      },\n    ],\n  },\n];\n"
  },
  {
    "path": "docs/.vitepress/theme/data/testimonials.ts",
    "content": "export interface TestimonialData {\n  quote: string;\n  logo: string;\n  logoAlt: string;\n  name: string;\n  title: string;\n  company: string;\n  image: string;\n}\n\nexport const testimonials: TestimonialData[] = [];\n"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "// note: import the specific variant directly!\nimport BaseTheme from '@voidzero-dev/vitepress-theme/src/viteplus';\nimport type { Theme } from 'vitepress';\n\nimport Layout from './Layout.vue';\nimport './styles.css';\n\nexport default {\n  extends: BaseTheme,\n  Layout,\n} satisfies Theme;\n"
  },
  {
    "path": "docs/.vitepress/theme/layouts/Error404.vue",
    "content": "<template>\n  <section id=\"error-404\">\n    <div class=\"container grid-background\">\n      <h2>Error 404 | Page not found</h2>\n      <h4>Should have caught this with Vitest, whoops 🤷</h4>\n      <a href=\"/\"><button>Return to home</button></a>\n    </div>\n  </section>\n</template>\n\n<style scoped>\n#error-404 {\n  .container {\n    padding: 200px 40px;\n    text-align: center;\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n    flex-wrap: nowrap;\n    gap: 40px;\n  }\n}\n</style>\n"
  },
  {
    "path": "docs/.vitepress/theme/layouts/Home.vue",
    "content": "<script setup lang=\"ts\">\nimport Spacer from '@components/shared/Spacer.vue';\n\nimport CoreFeature3Col from '../components/home/CoreFeature3Col.vue';\nimport FeatureCheck from '../components/home/FeatureCheck.vue';\nimport FeatureDevBuild from '../components/home/FeatureDevBuild.vue';\nimport FeaturePack from '../components/home/FeaturePack.vue';\nimport FeatureRun from '../components/home/FeatureRun.vue';\nimport FeatureTest from '../components/home/FeatureTest.vue';\nimport FeatureToolbar from '../components/home/FeatureToolbar.vue';\nimport Fullstack2Col from '../components/home/Fullstack2Col.vue';\nimport HeadingSection2 from '../components/home/HeadingSection2.vue';\nimport HeadingSection3 from '../components/home/HeadingSection3.vue';\nimport HeadingSection4 from '../components/home/HeadingSection4.vue';\nimport Hero from '../components/home/Hero.vue';\nimport HeroRive from '../components/home/HeroRive.vue';\nimport InstallCommand from '../components/home/InstallCommand.vue';\nimport PartnerLogos from '../components/home/PartnerLogos.vue';\nimport ProductivityGrid from '../components/home/ProductivityGrid.vue';\nimport Terminal from '../components/home/Terminal.vue';\nimport Testimonials from '../components/home/Testimonials.vue';\n</script>\n\n<template>\n  <Hero />\n  <Terminal />\n  <InstallCommand />\n  <CoreFeature3Col />\n  <ProductivityGrid />\n  <section id=\"features\" data-theme=\"dark\" class=\"bg-primary\">\n    <HeadingSection4 />\n    <FeatureToolbar />\n    <FeatureDevBuild />\n    <FeatureCheck />\n    <FeatureTest />\n    <FeatureRun />\n    <FeaturePack />\n  </section>\n  <HeadingSection3 />\n  <Fullstack2Col />\n  <Spacer />\n  <HeroRive />\n</template>\n"
  },
  {
    "path": "docs/.vitepress/theme/styles.css",
    "content": "/* styles.css */\n@import '@voidzero-dev/vitepress-theme/src/styles/index.css';\n\n@source \"./**/*.vue\";\n\n/* Viteplus */\n:root[data-variant='viteplus'] {\n  --color-brand: #4f30e8;\n}\n\n:root.dark:not([data-theme])[data-variant='viteplus'],\n:root[data-theme='dark'][data-variant='viteplus'] {\n  --color-brand: #6b77f8;\n}\n\n:root[data-theme='light'][data-variant='viteplus'] {\n  --color-brand: #4f30e8;\n}\n\n/* Update fonts for marketing pages */\n.marketing-layout {\n  --font-sans: 'APK Protocol', sans-serif;\n}\n\n:root[data-variant='viteplus'] {\n  --terminal-blue: color-mix(in srgb, var(--vp-c-indigo-2) 72%, white);\n}\n\n.terminal-copy {\n  max-width: 100%;\n  font-family: var(--font-mono);\n  min-height: 22rem;\n  padding: 0;\n  font-size: 0.875rem;\n  line-height: 1.5rem;\n  color: var(--color-white);\n}\n\n.terminal-prompt,\n.terminal-line {\n  white-space: pre-wrap;\n  word-break: break-word;\n}\n\n.terminal-spacer {\n  height: 1rem;\n}\n\n.terminal-line + .terminal-line {\n  margin-top: 0.35rem;\n}\n\n.terminal-tone-base {\n  color: var(--color-white);\n}\n\n.terminal-tone-muted {\n  color: color-mix(in srgb, var(--vp-c-text-2) 52%, white 48%);\n}\n\n.terminal-tone-brand {\n  color: var(--terminal-blue);\n}\n\n.terminal-tone-accent {\n  color: var(--terminal-blue);\n}\n\n.terminal-tone-success {\n  color: var(--color-zest);\n}\n\n.terminal-tone-warning {\n  color: var(--color-fire);\n}\n\n.terminal-cursor {\n  display: inline-block;\n  width: 0.62rem;\n  height: 1.05rem;\n  margin-left: 0.1rem;\n  vertical-align: -0.12rem;\n  border-radius: 2px;\n  background: var(--color-white);\n  animation: terminal-blink 0.95s steps(1, end) infinite;\n}\n\n.terminal-line-enter-active,\n.terminal-line-leave-active {\n  transition:\n    opacity 220ms ease,\n    transform 220ms ease;\n}\n\n.terminal-line-enter-from,\n.terminal-line-leave-to {\n  opacity: 0;\n  transform: translateY(0.35rem);\n}\n\n@keyframes terminal-blink {\n  0%,\n  49% {\n    opacity: 1;\n  }\n\n  50%,\n  100% {\n    opacity: 0;\n  }\n}\n\n@media (max-width: 640px) {\n  .terminal-copy {\n    font-size: 0.82rem;\n    line-height: 1.5rem;\n  }\n}\n\n.terminal-blue {\n  color: var(--terminal-blue);\n}\n"
  },
  {
    "path": "docs/.vitepress/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2024\", \"DOM\", \"DOM.Iterable\"],\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"noEmit\": false,\n    \"emitDeclarationOnly\": true,\n    \"outDir\": \"dist\",\n    \"allowImportingTsExtensions\": true,\n    \"strict\": true,\n    \"declaration\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"paths\": {\n      \"@local-assets/*\": [\"./theme/assets/*\"],\n      \"@assets/*\": [\"../node_modules/@voidzero-dev/vitepress-theme/src/assets/*\"],\n      \"@components/*\": [\"../node_modules/@voidzero-dev/vitepress-theme/src/components/*\"]\n    }\n  },\n  \"include\": [\"**/*.ts\", \"**/*.d.ts\", \"**/*.vue\"]\n}\n"
  },
  {
    "path": "docs/config/build.md",
    "content": "# Build Config\n\n`vp dev`, `vp build`, and `vp preview` use the standard [Vite configuration](https://vite.dev/config/), including [plugins](https://vite.dev/guide/using-plugins), [aliases](https://vite.dev/config/shared-options#resolve-alias), [`server`](https://vite.dev/config/server-options), [`build`](https://vite.dev/config/build-options) and [`preview`](https://vite.dev/config/preview-options) fields.\n\n## Example\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  server: {\n    port: 3000,\n  },\n  build: {\n    sourcemap: true,\n  },\n  preview: {\n    port: 4173,\n  },\n});\n```\n"
  },
  {
    "path": "docs/config/fmt.md",
    "content": "# Format Config\n\n`vp fmt` and `vp check` read Oxfmt settings from the `fmt` block in `vite.config.ts`. See [Oxfmt's configuration](https://oxc.rs/docs/guide/usage/formatter/config.html) for details.\n\n## Example\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  fmt: {\n    ignorePatterns: ['dist/**'],\n    singleQuote: true,\n    semi: true,\n    sortPackageJson: true,\n  },\n});\n```\n"
  },
  {
    "path": "docs/config/index.md",
    "content": "# Configuring Vite+\n\nVite+ keeps project configuration in one place: `vite.config.ts`, allowing you to consolidate many top-level configuration files in a single file. You can keep using your Vite configuration such as `server` or `build`, and add Vite+ blocks for the rest of your workflow:\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  server: {},\n  build: {},\n  preview: {},\n\n  test: {},\n  lint: {},\n  fmt: {},\n  run: {},\n  pack: {},\n  staged: {},\n});\n```\n\n## Vite+ Specific Configuration\n\nVite+ extends the basic Vite configuration with these additions:\n\n- [`lint`](/config/lint) for Oxlint\n- [`fmt`](/config/fmt) for Oxfmt\n- [`test`](/config/test) for Vitest\n- [`run`](/config/run) for Vite Task\n- [`pack`](/config/pack) for tsdown\n- [`staged`](/config/staged) for staged-file checks\n"
  },
  {
    "path": "docs/config/lint.md",
    "content": "# Lint Config\n\n`vp lint` and `vp check` read Oxlint settings from the `lint` block in `vite.config.ts`. See [Oxlint's configuration](https://oxc.rs/docs/guide/usage/linter/config.html) for details.\n\n## Example\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    ignorePatterns: ['dist/**'],\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n    rules: {\n      'no-console': ['error', { allow: ['error'] }],\n    },\n  },\n});\n```\n\nWe recommend enabling both `options.typeAware` and `options.typeCheck` so `vp lint` and `vp check` can use the full type-aware path.\n"
  },
  {
    "path": "docs/config/pack.md",
    "content": "# Pack Config\n\n`vp pack` reads tsdown settings from the `pack` block in `vite.config.ts`. See [tsdown's configuration](https://tsdown.dev/options/config-file) for details.\n\n## Example\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: {\n    dts: true,\n    format: ['esm', 'cjs'],\n    sourcemap: true,\n  },\n});\n```\n"
  },
  {
    "path": "docs/config/run.md",
    "content": "# Run Config\n\nYou can configure Vite Task under the `run` field in `vite.config.ts`. Check out [`vp run`](/guide/run) to learn more about running scripts and tasks with Vite+.\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  run: {\n    enablePrePostScripts: true,\n    cache: {\n      /* ... */\n    },\n    tasks: {\n      /* ... */\n    },\n  },\n});\n```\n\n## `run.enablePrePostScripts`\n\n- **Type:** `boolean`\n- **Default:** `true`\n\nWhether to automatically run `preX`/`postX` package.json scripts as lifecycle hooks when script `X` is executed.\n\nWhen enabled (the default), running a script like `test` will automatically run `pretest` before it and `posttest` after it, if they exist in `package.json`.\n\n```ts\nexport default defineConfig({\n  run: {\n    enablePrePostScripts: false, // Disable pre/post lifecycle hooks\n  },\n});\n```\n\n::: warning\nThis option can only be set in the workspace root's `vite.config.ts`. Setting it in a package's config will result in an error.\n:::\n\n## `run.cache`\n\n- **Type:** `boolean | { scripts?: boolean, tasks?: boolean }`\n- **Default:** `{ scripts: false, tasks: true }`\n\nControls whether task results are cached and replayed on subsequent runs.\n\n```ts\nexport default defineConfig({\n  run: {\n    cache: {\n      scripts: true, // Cache package.json scripts (default: false)\n      tasks: true, // Cache task definitions (default: true)\n    },\n  },\n});\n```\n\n`cache: true` enables both task and script caching, `cache: false` disables both.\n\n## `run.tasks`\n\n- **Type:** `Record<string, TaskConfig>`\n\nDefines tasks that can be run with `vp run <task>`.\n\n### `command`\n\n- **Type:** `string`\n\nDefines the shell command to run for the task.\n\n```ts\ntasks: {\n  build: {\n    command: 'vp build',\n  },\n}\n```\n\nEach task defined in `vite.config.ts` must include its own `command`. You cannot define a task in both `vite.config.ts` and `package.json` with the same task name.\n\nCommands joined with `&&` are automatically split into independently cached sub-tasks. See [Compound Commands](/guide/run#compound-commands).\n\n### `dependsOn`\n\n- **Type:** `string[]`\n- **Default:** `[]`\n\nTasks that must complete successfully before this one starts.\n\n```ts\ntasks: {\n  deploy: {\n    command: 'deploy-script --prod',\n    dependsOn: ['build', 'test'],\n  },\n}\n```\n\nDependencies can reference tasks in other packages using the `package#task` format:\n\n```ts\ndependsOn: ['@my/core#build', '@my/utils#lint'];\n```\n\nSee [Task Dependencies](/guide/run#task-dependencies) for details on how explicit and topological dependencies interact.\n\n### `cache`\n\n- **Type:** `boolean`\n- **Default:** `true`\n\nWhether to cache this task's output. Set to `false` for tasks that should never be cached, like dev servers:\n\n```ts\ntasks: {\n  dev: {\n    command: 'vp dev',\n    cache: false,\n  },\n}\n```\n\n### `env`\n\n- **Type:** `string[]`\n- **Default:** `[]`\n\nEnvironment variables included in the cache fingerprint. When any listed variable's value changes, the cache is invalidated.\n\n```ts\ntasks: {\n  build: {\n    command: 'vp build',\n    env: ['NODE_ENV'],\n  },\n}\n```\n\nWildcard patterns are supported: `VITE_*` matches all variables starting with `VITE_`.\n\n```bash\n$ NODE_ENV=development vp run build    # first run\n$ NODE_ENV=production vp run build     # cache miss: variable changed\n```\n\n### `untrackedEnv`\n\n- **Type:** `string[]`\n- **Default:** see below\n\nEnvironment variables passed to the task process but **not** included in the cache fingerprint. Changing these values won't invalidate the cache.\n\n```ts\ntasks: {\n  build: {\n    command: 'vp build',\n    untrackedEnv: ['CI', 'GITHUB_ACTIONS'],\n  },\n}\n```\n\nA set of common environment variables are automatically passed through to all tasks:\n\n- **System:** `HOME`, `USER`, `PATH`, `SHELL`, `LANG`, `TZ`\n- **Node.js:** `NODE_OPTIONS`, `COREPACK_HOME`, `PNPM_HOME`\n- **CI/CD:** `CI`, `VERCEL_*`, `NEXT_*`\n- **Terminal:** `TERM`, `COLORTERM`, `FORCE_COLOR`, `NO_COLOR`\n\n### `input`\n\n- **Type:** `Array<string | { auto: boolean }>`\n- **Default:** `[{ auto: true }]` (auto-inferred)\n\nVite Task automatically detects which files are used by a command (see [Automatic File Tracking](/guide/cache#automatic-file-tracking)). The `input` option can be used to explicitly include or exclude certain files.\n\n**Exclude files** from automatic tracking:\n\n```ts\ntasks: {\n  build: {\n    command: 'vp build',\n    // Use `{ auto: true }` to use automatic fingerprinting (default).\n    input: [{ auto: true }, '!**/*.tsbuildinfo', '!dist/**'],\n  },\n}\n```\n\n**Specify explicit files** only without automatic tracking:\n\n```ts\ntasks: {\n  build: {\n    command: 'vp build',\n    input: ['src/**/*.ts', 'vite.config.ts'],\n  },\n}\n```\n\n**Disable file tracking** entirely and cache only on command/env changes:\n\n```ts\ntasks: {\n  greet: {\n    command: 'node greet.mjs',\n    input: [],\n  },\n}\n```\n\n::: tip\nGlob patterns are resolved relative to the package directory, not the task's `cwd`.\n:::\n\n### `cwd`\n\n- **Type:** `string`\n- **Default:** package root\n\nWorking directory for the task, relative to the package root.\n\n```ts\ntasks: {\n  'test-e2e': {\n    command: 'vp test',\n    cwd: 'tests/e2e',\n  },\n}\n```\n"
  },
  {
    "path": "docs/config/staged.md",
    "content": "# Staged Config\n\n`vp staged` and `vp config` read staged-file rules from the `staged` block in `vite.config.ts`. See the [Commit hooks guide](/guide/commit-hooks).\n\n## Example\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    '*.{js,ts,tsx,vue,svelte}': 'vp check --fix',\n  },\n});\n```\n"
  },
  {
    "path": "docs/config/test.md",
    "content": "# Test Config\n\n`vp test` reads Vitest settings from the `test` block in `vite.config.ts`. See [Vitest's configuration](https://vitest.dev/config/) for details.\n\n## Example\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  test: {\n    include: ['src/**/*.test.ts'],\n    coverage: {\n      reporter: ['text', 'html'],\n    },\n  },\n});\n```\n"
  },
  {
    "path": "docs/guide/build.md",
    "content": "# Build\n\n`vp build` builds Vite applications for production.\n\n## Overview\n\n`vp build` runs the standard Vite production build through Vite+. Since it is directly based on Vite, the build pipeline and configuration model are the same as Vite. For more information about how Vite production builds work, see the [Vite guide](https://vite.dev/guide/build). Note that Vite+ uses Vite 8 and [Rolldown](https://rolldown.rs/) for builds.\n\n::: info\n`vp build` always runs the built-in Vite production build. If your project also has a `build` script in `package.json`, run `vp run build` when you want to run that script instead.\n:::\n\n## Usage\n\n```bash\nvp build\nvp build --watch\nvp build --sourcemap\n```\n\n## Configuration\n\nUse standard Vite configuration in `vite.config.ts`. For the full configuration reference, see the [Vite config docs](https://vite.dev/config/).\n\nUse it for:\n\n- [plugins](https://vite.dev/guide/using-plugins)\n- [aliases](https://vite.dev/config/shared-options#resolve-alias)\n- [`build`](https://vite.dev/config/build-options)\n- [`preview`](https://vite.dev/config/preview-options)\n- [environment modes](https://vite.dev/guide/env-and-mode)\n\n## Preview\n\nUse `vp preview` to serve the production build locally after `vp build`.\n\n```bash\nvp build\nvp preview\n```\n"
  },
  {
    "path": "docs/guide/cache.md",
    "content": "# Task Caching\n\nVite Task can automatically track dependencies and cache tasks run through `vp run`.\n\n## Overview\n\nWhen a task runs successfully (exit code 0), its terminal output (stdout/stderr) is saved. On the next run, Vite Task checks if anything changed:\n\n1. **Arguments:** did the [additional arguments](/guide/run#additional-arguments) passed to the task change?\n2. **Environment variables:** did any [fingerprinted env vars](/config/run#env) change?\n3. **Input files:** did any file that the command reads change?\n\nIf everything matches, the cached output is replayed instantly, and the command does not run.\n\n::: info\nCurrently, only terminal output is cached and replayed. Output files such as `dist/` are not cached. If you delete them, use `--no-cache` to force a re-run. Output file caching is planned for a future release.\n:::\n\nWhen a cache miss occurs, Vite Task tells you exactly why:\n\n```\n$ vp lint ✗ cache miss: 'src/utils.ts' modified, executing\n$ vp build ✗ cache miss: env changed, executing\n$ vp test ✗ cache miss: args changed, executing\n```\n\n## When Is Caching Enabled?\n\nA command run by `vp run` is either a **task** defined in `vite.config.ts` or a **script** defined in `package.json`. Task names and script names cannot overlap. By default, **tasks are cached and scripts are not.**\n\nThere are three types of controls for task caching, in order:\n\n### 1. Per-task `cache: false`\n\nA task can set [`cache: false`](/config/run#cache) to opt out. This cannot be overridden by any other cache control flag.\n\n### 2. CLI flags\n\n`--no-cache` disables caching for everything. `--cache` enables caching for both tasks and scripts, which is equivalent to setting [`run.cache: true`](/config/run#run-cache) for that invocation.\n\n### 3. Workspace config\n\nThe [`run.cache`](/config/run#run-cache) option in your root `vite.config.ts` controls the default for each category:\n\n| Setting         | Default | Effect                                  |\n| --------------- | ------- | --------------------------------------- |\n| `cache.tasks`   | `true`  | Cache tasks defined in `vite.config.ts` |\n| `cache.scripts` | `false` | Cache `package.json` scripts            |\n\n## Automatic File Tracking\n\nVite Task tracks which files each command reads during execution. When a task runs, it records which files the process opens, such as your `.ts` source files, `vite.config.ts`, and `package.json`, and records their content hashes. On the next run, it re-checks those hashes to determine if anything changed.\n\nThis means caching works out of the box for most commands without any configuration. Vite Task also records:\n\n- **Missing files:** if a command probes for a file that doesn't exist, such as `utils.ts` during module resolution, creating that file later correctly invalidates the cache.\n- **Directory listings:** if a command scans a directory, such as a test runner looking for `*.test.ts`, adding or removing files in that directory invalidates the cache.\n\n### Avoiding Overly Broad Input Tracking\n\nAutomatic tracking can sometimes include more files than necessary, causing unnecessary cache misses:\n\n- **Tool cache files:** some tools maintain their own cache, such as TypeScript's `.tsbuildinfo` or Cargo's `target/`. These files may change between runs even when your source code has not, causing unnecessary cache invalidation.\n- **Directory listings:** when a command scans a directory, such as when globbing for `**/*.js`, Vite Task sees the directory read but not the glob pattern. Any file added or removed in that directory, even unrelated ones, invalidates the cache.\n\nUse the [`input`](/config/run#input) option to exclude files or to replace automatic tracking with explicit file patterns:\n\n```ts\ntasks: {\n  build: {\n    command: 'tsc',\n    input: [{ auto: true }, '!**/*.tsbuildinfo'],\n  },\n}\n```\n\n## Environment Variables\n\nBy default, tasks run in a clean environment. Only a small set of common variables, such as `PATH`, `HOME`, and `CI`, are passed through. Other environment variables are neither visible to the task nor included in the cache fingerprint.\n\nTo add an environment variable to the cache key, add it to [`env`](/config/run#env). Changing its value then invalidates the cache:\n\n```ts\ntasks: {\n  build: {\n    command: 'webpack --mode production',\n    env: ['NODE_ENV'],\n  },\n}\n```\n\nTo pass a variable to the task **without** affecting cache behavior, use [`untrackedEnv`](/config/run#untracked-env). This is useful for variables like `CI` or `GITHUB_ACTIONS` that should be available in the task, but do not generally affect caching behavior.\n\nSee [Run Config](/config/run#env) for details on wildcard patterns and the full list of automatically passed-through variables.\n\n## Cache Sharing\n\nVite Task's cache is content-based. If two tasks run the same command with the same inputs, they share the cache entry. This happens naturally when multiple tasks include a common step, either as standalone tasks or as parts of [compound commands](/guide/run#compound-commands):\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"check\": \"vp lint && vp build\",\n    \"release\": \"vp lint && deploy-script\"\n  }\n}\n```\n\nWith caching enabled, for example through `--cache` or [`run.cache.scripts: true`](/config/run#run-cache), running `check` first means the `vp lint` step in `release` is an instant cache hit, since both run the same command against the same files.\n\n## Cache Commands\n\nUse `vp cache clean` when you need to clear cached task results:\n\n```bash\nvp cache clean\n```\n\nThe task cache is stored in `node_modules/.vite/task-cache` at the project root. `vp cache clean` deletes that cache directory.\n"
  },
  {
    "path": "docs/guide/check.md",
    "content": "# Check\n\n`vp check` runs format, lint, and type checks together.\n\n## Overview\n\n`vp check` is the default command for fast static checks in Vite+. It brings together formatting through [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), linting through [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), and TypeScript type checks through [tsgolint](https://github.com/oxc-project/tsgolint). By merging all of these tasks into a single command, `vp check` is faster than running formatting, linting, and type checking as separate tools in separate commands.\n\nWhen `typeCheck` is enabled in the `lint.options` block in `vite.config.ts`, `vp check` also runs TypeScript type checks through the Oxlint type-aware path powered by the TypeScript Go toolchain and [tsgolint](https://github.com/oxc-project/tsgolint). `vp create` and `vp migrate` enable both `typeAware` and `typeCheck` by default.\n\nWe recommend turning `typeCheck` on so `vp check` becomes the single command for static checks during development.\n\n## Usage\n\n```bash\nvp check\nvp check --fix # Format and run autofixers.\n```\n\n## Configuration\n\n`vp check` uses the same configuration you already define for linting and formatting:\n\n- [`lint`](/guide/lint#configuration) block in `vite.config.ts`\n- [`fmt`](/guide/fmt#configuration) block in `vite.config.ts`\n- TypeScript project structure and tsconfig files for type-aware linting\n\nRecommended base `lint` config:\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n  },\n});\n```\n"
  },
  {
    "path": "docs/guide/ci.md",
    "content": "# Continuous Integration\n\nYou can use `voidzero-dev/setup-vp` to use Vite+ in CI environments.\n\n## Overview\n\nFor GitHub Actions, the recommended setup is [`voidzero-dev/setup-vp`](https://github.com/voidzero-dev/setup-vp). It installs Vite+, sets up the required Node.js version and package manager, and can cache package installs automatically.\n\nThat means you usually do not need separate `setup-node`, package-manager setup, and manual dependency-cache steps in your workflow.\n\n## GitHub Actions\n\n```yaml\n- uses: voidzero-dev/setup-vp@v1\n  with:\n    node-version: '22'\n    cache: true\n- run: vp install\n- run: vp check\n- run: vp test\n- run: vp build\n```\n\nWith `cache: true`, `setup-vp` handles dependency caching for you automatically.\n\n## Simplifying Existing Workflows\n\nIf you are migrating an existing GitHub Actions workflow, you can often replace large blocks of Node, package-manager, and cache setup with a single `setup-vp` step.\n\n#### Before:\n\n```yaml\n- uses: actions/setup-node@v4\n  with:\n    node-version: '24'\n\n- uses: pnpm/action-setup@v4\n  with:\n    version: 10\n\n- name: Get pnpm store path\n  run: pnpm store path\n\n- uses: actions/cache@v4\n  with:\n    path: ~/.pnpm-store\n    key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}\n\n- run: pnpm install && pnpm dev:setup\n- run: pnpm test\n```\n\n#### After:\n\n```yaml\n- uses: voidzero-dev/setup-vp@v1\n  with:\n    node-version: '24'\n    cache: true\n\n- run: vp install && vp run dev:setup\n- run: vp check\n- run: vp test\n```\n"
  },
  {
    "path": "docs/guide/commit-hooks.md",
    "content": "# Commit Hooks\n\nUse `vp config` to install commit hooks, and `vp staged` to run checks on staged files.\n\n## Overview\n\nVite+ supports commit hooks and staged-file checks without additional tooling.\n\nUse:\n\n- `vp config` to set up project hooks and related integrations\n- `vp staged` to run checks against the files currently staged in Git\n\nIf you use [`vp create`](/guide/create) or [`vp migrate`](/guide/migrate), Vite+ prompts you to set this up for your project automatically.\n\n## Commands\n\n### `vp config`\n\n`vp config` configures Vite+ for the current project. It installs Git hooks, sets up the hook directory, and can also handle related project integration such as agent setup. By default, hooks are written to `.vite-hooks`:\n\n```bash\nvp config\nvp config --hooks-dir .vite-hooks\n```\n\n### `vp staged`\n\n`vp staged` runs staged-file checks using the `staged` config from `vite.config.ts`. If you set up Vite+ to handle your commit hooks, it will automatically run when you commit your local changes.\n\n```bash\nvp staged\nvp staged --verbose\nvp staged --fail-on-changes\n```\n\n## Configuration\n\nDefine staged-file checks in the `staged` block in `vite.config.ts`:\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    '*.{js,ts,tsx,vue,svelte}': 'vp check --fix',\n  },\n});\n```\n\nThis is the default Vite+ approach and should replace separate `lint-staged` configuration in most projects. Because `vp staged` reads from `vite.config.ts`, your staged-file checks stay in the same place as your lint, format, test, build, and task-runner config.\n"
  },
  {
    "path": "docs/guide/create.md",
    "content": "# Creating a Project\n\n`vp create` interactively scaffolds new Vite+ projects, monorepos, and apps inside existing workspaces.\n\n## Overview\n\nThe `create` command is the fastest way to start with Vite+. It can be used in a few different ways:\n\n- Start a new Vite+ monorepo\n- Create a new standalone application or library\n- Add a new app or library inside an existing project\n\nThis command can be used with built-in templates, community templates, or remote GitHub templates.\n\n## Usage\n\n```bash\nvp create\nvp create <template>\nvp create <template> -- <template-options>\n```\n\n## Built-in Templates\n\nVite+ ships with these built-in templates:\n\n- `vite:monorepo` creates a new monorepo\n- `vite:application` creates a new application\n- `vite:library` creates a new library\n- `vite:generator` creates a new generator\n\n## Template Sources\n\n`vp create` is not limited to the built-in templates.\n\n- Use shorthand templates like `vite`, `@tanstack/start`, `svelte`, `next-app`, `nuxt`, `react-router`, and `vue`\n- Use full package names like `create-vite` or `create-next-app`\n- Use local templates such as `./tools/create-ui-component` or `@acme/generator-*`\n- Use remote templates such as `github:user/repo` or `https://github.com/user/template-repo`\n\nRun `vp create --list` to see the built-in templates and the common shorthand templates Vite+ recognizes.\n\n## Options\n\n- `--directory <dir>` writes the generated project into a specific target directory\n- `--agent <name>` creates agent instructions files during scaffolding\n- `--editor <name>` writes editor config files\n- `--hooks` enables pre-commit hook setup\n- `--no-hooks` skips hook setup\n- `--no-interactive` runs without prompts\n- `--verbose` shows detailed scaffolding output\n- `--list` prints the available built-in and popular templates\n\n## Template Options\n\nArguments after `--` are passed directly to the selected template.\n\nThis matters when the template itself accepts flags. For example, you can forward Vite template selection like this:\n\n```bash\nvp create vite -- --template react-ts\n```\n\n## Examples\n\n```bash\n# Interactive mode\nvp create\n\n# Create a Vite+ monorepo, application, library, or generator\nvp create vite:monorepo\nvp create vite:application\nvp create vite:library\nvp create vite:generator\n\n# Use shorthand community templates\nvp create vite\nvp create @tanstack/start\nvp create svelte\n\n# Use full package names\nvp create create-vite\nvp create create-next-app\n\n# Use remote templates\nvp create github:user/repo\nvp create https://github.com/user/template-repo\n```\n"
  },
  {
    "path": "docs/guide/dev.md",
    "content": "# Dev\n\n`vp dev` starts the Vite development server.\n\n## Overview\n\n`vp dev` runs the standard Vite development server through Vite+, so you keep the normal Vite dev experience while using the same CLI entry point as the rest of the toolchain. For more information about using and configuring the dev server, see the [Vite guide](https://vite.dev/guide/).\n\n## Usage\n\n```bash\nvp dev\n```\n\n## Configuration\n\nUse standard Vite config in `vite.config.ts`. For the full configuration reference, see the [Vite config docs](https://vite.dev/config/).\n\nUse it for:\n\n- [plugins](https://vite.dev/guide/using-plugins)\n- [aliases](https://vite.dev/config/shared-options#resolve-alias)\n- [`server`](https://vite.dev/config/server-options)\n- [environment modes](https://vite.dev/guide/env-and-mode)\n"
  },
  {
    "path": "docs/guide/env.md",
    "content": "# Environment\n\n`vp env` manages Node.js versions globally and per project.\n\n## Overview\n\nManaged mode is on by default, so `node`, `npm`, and related shims resolve through Vite+ and pick the right Node.js version for the current project.\n\nBy default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VITE_PLUS_HOME`.\n\nIf you want to keep that behavior, run:\n\n```bash\nvp env on\n```\n\nThis enables managed mode, where the shims always use the Vite+-managed Node.js installation.\n\nIf you do not want Vite+ to manage Node.js first, run:\n\n```bash\nvp env off\n```\n\nThis switches to system-first mode, where the shims prefer your system Node.js and only fall back to the Vite+-managed runtime when needed.\n\n## Commands\n\n### Setup\n\n- `vp env setup` creates or updates shims in `VITE_PLUS_HOME/bin`\n- `vp env on` enables managed mode so shims always use Vite+-managed Node.js\n- `vp env off` enables system-first mode so shims prefer system Node.js first\n- `vp env print` prints the shell snippet for the current session\n\n### Manage\n\n- `vp env default` sets or shows the global default Node.js version\n- `vp env pin` pins a Node.js version in the current directory\n- `vp env unpin` removes `.node-version` from the current directory\n- `vp env use` sets a Node.js version for the current shell session\n- `vp env install` installs a Node.js version\n- `vp env uninstall` removes an installed Node.js version\n- `vp env exec` runs a command with a specific Node.js version\n\n### Inspect\n\n- `vp env current` shows the current resolved environment\n- `vp env doctor` runs environment diagnostics\n- `vp env which` shows which tool path will be used\n- `vp env list` shows locally installed Node.js versions\n- `vp env list-remote` shows available Node.js versions from the registry\n\n## Project Setup\n\n- Pin a project version with `.node-version`\n- Use `vp install`, `vp dev`, and `vp build` normally\n- Let Vite+ pick the right runtime for the project\n\n## Examples\n\n```bash\n# Setup\nvp env setup                  # Create shims for node, npm, npx\nvp env on                     # Use Vite+ managed Node.js\nvp env print                  # Print shell snippet for this session\n\n# Manage\nvp env pin lts                # Pin the project to the latest LTS release\nvp env install                # Install the version from .node-version or package.json\nvp env default lts            # Set the global default version\nvp env use 20                 # Use Node.js 20 for the current shell session\nvp env use --unset            # Remove the session override\n\n# Inspect\nvp env current                # Show current resolved environment\nvp env current --json         # JSON output for automation\nvp env which node             # Show which node binary will be used\nvp env list-remote --lts      # List only LTS versions\n\n# Execute\nvp env exec --node lts npm i  # Execute npm with latest LTS\nvp env exec node -v           # Use shim mode with automatic version resolution\n```\n"
  },
  {
    "path": "docs/guide/fmt.md",
    "content": "# Format\n\n`vp fmt` formats code with Oxfmt.\n\n## Overview\n\n`vp fmt` is built on [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), the Oxc formatter. Oxfmt has full Prettier compatibility and is designed as a fast drop-in replacement for Prettier.\n\nUse `vp fmt` to format your project, and `vp check` to format, lint and type-check all at once.\n\n## Usage\n\n```bash\nvp fmt\nvp fmt --check\nvp fmt . --write\n```\n\n## Configuration\n\nPut formatting configuration directly in the `fmt` block in `vite.config.ts` so all your configuration stays in one place. We do not recommend using `.oxfmtrc.json` with Vite+.\n\nFor editors, point the formatter config path at `./vite.config.ts` so format-on-save uses the same `fmt` block:\n\n```json\n{\n  \"oxc.fmt.configPath\": \"./vite.config.ts\"\n}\n```\n\nFor the upstream formatter behavior and configuration reference, see the [Oxfmt docs](https://oxc.rs/docs/guide/usage/formatter.html).\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  fmt: {\n    singleQuote: true,\n  },\n});\n```\n"
  },
  {
    "path": "docs/guide/ide-integration.md",
    "content": "# IDE Integration\n\nVite+ supports VS Code through the [Vite Plus Extension Pack](https://marketplace.visualstudio.com/items?itemName=VoidZero.vite-plus-extension-pack) and the VS Code settings that `vp create` and `vp migrate` can automatically write into your project.\n\n## VS Code\n\nFor the best VS Code experience with Vite+, install the [Vite Plus Extension Pack](https://marketplace.visualstudio.com/items?itemName=VoidZero.vite-plus-extension-pack). It currently includes:\n\n- `Oxc` for formatting and linting via `vp check`\n- `Vitest` for test runs via `vp test`\n\nWhen you create or migrate a project, Vite+ prompts whether you want editor config written for VS Code. You can also manually set up the VS Code config:\n\n`.vscode/extensions.json`\n\n```json\n{\n  \"recommendations\": [\"VoidZero.vite-plus-extension-pack\"]\n}\n```\n\n`.vscode/settings.json`\n\n```json\n{\n  \"editor.defaultFormatter\": \"oxc.oxc-vscode\",\n  \"oxc.fmt.configPath\": \"./vite.config.ts\",\n  \"editor.formatOnSave\": true,\n  \"editor.formatOnSaveMode\": \"file\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.oxc\": \"explicit\"\n  }\n}\n```\n\nThis gives the project a shared default formatter and enables Oxc-powered fix actions on save. Setting `oxc.fmt.configPath` to `./vite.config.ts` keeps editor format-on-save aligned with the `fmt` block in your Vite+ config. Vite+ uses `formatOnSaveMode: \"file\"` because Oxfmt does not support partial formatting.\n"
  },
  {
    "path": "docs/guide/implode.md",
    "content": "# Removing Vite+\n\nUse `vp implode` to remove `vp` and all related Vite+ data from your machine.\n\n## Overview\n\n`vp implode` is the cleanup command for removing a Vite+ installation and its managed data. Use it if you no longer want Vite+ to manage your runtime, package manager, and related local tooling state.\n\n::: info\nIf you decide Vite+ is not for you, please [share your feedback with us](https://discord.gg/cAnsqHh5PX).\n:::\n\n## Usage\n\n```bash\nvp implode\n```\n\nSkip the confirmation prompt with:\n\n```bash\nvp implode --yes\n```\n"
  },
  {
    "path": "docs/guide/index.md",
    "content": "# Getting Started\n\nVite+ is the unified toolchain and entry point for web development. It manages your runtime, package manager, and frontend toolchain in one place by combining [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task).\n\nVite+ ships in two parts: `vp`, the global command-line tool, and `vite-plus`, the local package installed in each project. If you already have a Vite project, use [`vp migrate`](/guide/migrate) to migrate it to Vite+, or paste our [migration prompt](/guide/migrate#migration-prompt) into your coding agent.\n\n## Install `vp`\n\n### macOS / Linux\n\n```bash\ncurl -fsSL https://vite.plus | bash\n```\n\n### Windows\n\n```powershell\nirm https://vite.plus/ps1 | iex\n```\n\nAfter installation, open a new shell and run:\n\n```bash\nvp help\n```\n\n::: info\nVite+ will manage your global Node.js runtime and package manager. If you'd like to opt out of this behavior, run `vp env off`. If you realize Vite+ is not for you, type `vp implode`, but please [share your feedback with us](https://discord.gg/cAnsqHh5PX).\n:::\n\n## Quick Start\n\nCreate a project, install dependencies, and use the default commands:\n\n```bash\nvp create # Create a new project\nvp install # Install dependencies\nvp dev # Start the dev server\nvp check # Format, lint, type-check\nvp test # Run JavaScript tests\nvp build # Build for production\n```\n\nYou can also just run `vp` on its own and use the interactive command line.\n\n## Core Commands\n\nVite+ can handle the entire local frontend development cycle from starting a project, developing it, checking & testing, and building it for production.\n\n### Start\n\n- [`vp create`](/guide/create) creates new apps, packages, and monorepos.\n- [`vp migrate`](/guide/migrate) moves existing projects onto Vite+.\n- [`vp config`](/guide/commit-hooks) configures commit hooks and agent integration.\n- [`vp staged`](/guide/commit-hooks) runs checks on staged files.\n- [`vp install`](/guide/install) installs dependencies with the right package manager.\n- [`vp env`](/guide/env) manages Node.js versions.\n\n### Develop\n\n- [`vp dev`](/guide/dev) starts the dev server powered by Vite.\n- [`vp check`](/guide/check) runs format, lint, and type checks together.\n- [`vp lint`](/guide/lint), [`vp fmt`](/guide/fmt), and [`vp test`](/guide/test) let you run those tools directly.\n\n### Execute\n\n- [`vp run`](/guide/run) runs tasks across workspaces with caching.\n- [`vp cache`](/guide/cache) clears task cache entries.\n- [`vpx`](/guide/vpx) runs binaries globally.\n- [`vp exec`](/guide/vpx) runs local project binaries.\n- [`vp dlx`](/guide/vpx) runs package binaries without adding them as dependencies.\n\n### Build\n\n- [`vp build`](/guide/build) builds apps.\n- [`vp pack`](/guide/pack) builds libraries or standalone artifacts.\n- [`vp preview`](/guide/build) previews the production build locally.\n\n### Manage Dependencies\n\n- [`vp add`](/guide/install), [`vp remove`](/guide/install), [`vp update`](/guide/install), [`vp dedupe`](/guide/install), [`vp outdated`](/guide/install), [`vp why`](/guide/install), and [`vp info`](/guide/install) wrap package-manager workflows.\n- [`vp pm <command>`](/guide/install) calls other package manager commands directly.\n\n### Maintain\n\n- [`vp upgrade`](/guide/upgrade) updates the `vp` installation itself.\n- [`vp implode`](/guide/implode) removes `vp` and related Vite+ data from your machine.\n\n::: info\nVite+ ships with many predefined commands such as `vp build`, `vp test`, and `vp dev`. These commands are built in and cannot be changed. If you want to run a command from your `package.json` scripts, use `vp run <command>`.\n\n[Learn more about `vp run`.](/guide/run)\n:::\n"
  },
  {
    "path": "docs/guide/install.md",
    "content": "# Installing Dependencies\n\n`vp install` installs dependencies using the current workspace's package manager.\n\n## Overview\n\nUse Vite+ to manage dependencies across pnpm, npm, and Yarn. Instead of switching between `pnpm install`, `npm install`, and `yarn install`, you can keep using `vp install`, `vp add`, `vp remove`, and the rest of the Vite+ package-management commands.\n\nVite+ detects the package manager from the workspace root in this order:\n\n1. `packageManager` in `package.json`\n2. `pnpm-workspace.yaml`\n3. `pnpm-lock.yaml`\n4. `yarn.lock` or `.yarnrc.yml`\n5. `package-lock.json`\n6. `.pnpmfile.cjs` or `pnpmfile.cjs`\n7. `yarn.config.cjs`\n\nIf none of those files are present, `vp` falls back to `pnpm` by default. Vite+ automatically downloads the matching package manager and uses it for the command you ran.\n\n## Usage\n\n```bash\nvp install\n```\n\nCommon install flows:\n\n```bash\nvp install\nvp install --frozen-lockfile\nvp install --lockfile-only\nvp install --filter web\nvp install -w\n```\n\n`vp install` maps to the correct underlying install behavior for the detected package manager, including the right lockfile flags for pnpm, npm, and Yarn.\n\n## Global Packages\n\nUse the `-g` flag for installing, updating or removing globally installed packages:\n\n- `vp install -g <pkg>` installs a package globally\n- `vp uninstall -g <pkg>` removes a global package\n- `vp update -g [pkg]` updates one global package or all of them\n- `vp list -g [pkg]` lists global packages\n\n## Managing Dependencies\n\nVite+ provides all the familiar package management commands:\n\n- `vp install` installs the current dependency graph for the project\n- `vp add <pkg>` adds packages to `dependencies`, use `-D` for `devDependencies`\n- `vp remove <pkg>` removes packages\n- `vp update` updates dependencies\n- `vp dedupe` reduces duplicate dependency entries where the package manager supports it\n- `vp outdated` shows available updates\n- `vp list` shows installed packages\n- `vp why <pkg>` explains why a package is present\n- `vp info <pkg>` shows registry metadata for a package\n- `vp link` and `vp unlink` manage local package links\n- `vp dlx <pkg>` runs a package binary without adding it to the project\n- `vp pm <command>` forwards a raw package-manager-specific command when you need behavior outside the normalized `vp` command set\n\n### Command Guide\n\n#### Install\n\nUse `vp install` when you want to install exactly what the current `package.json` and lockfile describe.\n\n- `vp install` is the standard install command\n- `vp install --frozen-lockfile` fails if the lockfile would need changes\n- `vp install --no-frozen-lockfile` allows lockfile updates explicitly\n- `vp install --lockfile-only` updates the lockfile without performing a full install\n- `vp install --prefer-offline` and `vp install --offline` prefer or require cached packages\n- `vp install --ignore-scripts` skips lifecycle scripts\n- `vp install --filter <pattern>` scopes install work in monorepos\n- `vp install -w` installs in the workspace root\n\n#### Global Install\n\nUse these commands when you want package-manager-managed tools available outside a single project.\n\n- `vp install -g typescript`\n- `vp uninstall -g typescript`\n- `vp update -g`\n- `vp list -g`\n\n#### Add and Remove\n\nUse `vp add` and `vp remove` for day-to-day dependency edits instead of editing `package.json` by hand.\n\n- `vp add react`\n- `vp add -D typescript vitest`\n- `vp add -O fsevents`\n- `vp add --save-peer react`\n- `vp remove react`\n- `vp remove --filter web react`\n\n#### Update, Dedupe, and Outdated\n\nUse these commands to maintain the dependency graph over time.\n\n- `vp update` refreshes packages to newer versions\n- `vp outdated` shows which packages have newer versions available\n- `vp dedupe` asks the package manager to collapse duplicates where possible\n\n#### Inspect\n\nUse these when you need to understand the current state of dependencies.\n\n- `vp list` shows installed packages\n- `vp why react` explains why `react` is installed\n- `vp info react` shows registry metadata such as versions and dist-tags\n\n#### Advanced\n\nUse these when you need lower-level package-manager behavior.\n\n- `vp link` and `vp unlink` manage local development links\n- `vp dlx create-vite` runs a package binary without saving it as a dependency\n- `vp pm <command>` forwards directly to the resolved package manager\n\nExamples:\n\n```bash\nvp pm config get registry\nvp pm cache clean --force\nvp pm exec tsc --version\n```\n"
  },
  {
    "path": "docs/guide/lint.md",
    "content": "# Lint\n\n`vp lint` lints code with Oxlint.\n\n## Overview\n\n`vp lint` is built on [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), the Oxc linter. Oxlint is designed as a fast replacement for ESLint for most frontend projects and ships with built-in support for core ESLint rules and many popular community rules.\n\nUse `vp lint` to lint your project, and `vp check` to format, lint and type-check all at once.\n\n## Usage\n\n```bash\nvp lint\nvp lint --fix\nvp lint --type-aware\n```\n\n## Configuration\n\nPut lint configuration directly in the `lint` block in `vite.config.ts` so all your configuration stays in one place. We do not recommend using `oxlint.config.ts` or `.oxlintrc.json` with Vite+.\n\nFor the upstream rule set, options, and compatibility details, see the [Oxlint docs](https://oxc.rs/docs/guide/usage/linter.html).\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    ignorePatterns: ['dist/**'],\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n  },\n});\n```\n\n## Type-Aware Linting\n\nWe recommend enabling both `typeAware` and `typeCheck` in the `lint` block:\n\n- `typeAware: true` enables rules that require TypeScript type information\n- `typeCheck: true` enables full type checking during linting\n\nThis path is powered by [tsgolint](https://github.com/oxc-project/tsgolint) on top of the TypeScript Go toolchain. It gives Oxlint access to type information and allows type checking directly via `vp lint` and `vp check`.\n\n## JS Plugins\n\nIf you are migrating from ESLint and still depend on a few critical JavaScript-based ESLint plugins, Oxlint has [JS plugin support](https://oxc.rs/docs/guide/usage/linter/js-plugins) that can help you keep those plugins running while you complete the migration.\n"
  },
  {
    "path": "docs/guide/migrate.md",
    "content": "# Migrate to Vite+\n\n`vp migrate` helps move existing projects onto Vite+.\n\n## Overview\n\nThis command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, and Prettier setups into Vite+.\n\nUse it when you want to take an existing project and move it onto the Vite+ defaults instead of wiring each tool by hand.\n\n## Usage\n\n```bash\nvp migrate\nvp migrate <path>\nvp migrate --no-interactive\n```\n\n## Target Path\n\nThe positional `PATH` argument is optional.\n\n- If omitted, `vp migrate` migrates the current directory\n- If provided, it migrates that target directory instead\n\n```bash\nvp migrate\nvp migrate my-app\n```\n\n## Options\n\n- `--agent <name>` writes agent instructions into the project\n- `--no-agent` skips agent instruction setup\n- `--editor <name>` writes editor config files into the project\n- `--no-editor` skips editor config setup\n- `--hooks` sets up pre-commit hooks\n- `--no-hooks` skips hook setup\n- `--no-interactive` runs the migration without prompts\n\n## Migration Flow\n\nThe `migrate` command is designed to move existing projects onto Vite+ quickly. Here is what the command does:\n\n- Updates project dependencies\n- Rewrites imports where needed\n- Merges tool-specific config into `vite.config.ts`\n- Updates scripts to the Vite+ command surface\n- Can set up commit hooks\n- Can write agent and editor configuration files\n\nMost projects will require further manual adjustments after running `vp migrate`.\n\n## Recommended Workflow\n\nBefore running the migration:\n\n- Upgrade to Vite 8+ and Vitest 4.1+ first\n- Make sure you understand any existing lint, format, or test setup that should be preserved\n\nAfter running the migration:\n\n- Run `vp install`\n- Run `vp check`\n- Run `vp test`\n- Run `vp build`\n\n## Migration Prompt\n\nIf you want to hand this work to a coding agent (or the reader is a coding agent!), use this migration prompt:\n\n```md\nMigrate this project to Vite+. Vite+ replaces the current split tooling around runtime management, package management, dev/build/test commands, linting, formatting, and packaging. Run `vp help` to understand Vite+ capabilities and `vp help migrate` before making changes. Use `vp migrate --no-interactive` in the workspace root. Make sure the project is using Vite 8+ and Vitest 4.1+ before migrating.\n\nAfter the migration:\n\n- Confirm `vite` imports were rewritten to `vite-plus` where needed\n- Confirm `vitest` imports were rewritten to `vite-plus/test` where needed\n- Remove old `vite` and `vitest` dependencies only after those rewrites are confirmed\n- Move remaining tool-specific config into the appropriate blocks in `vite.config.ts`\n\nCommand mapping to keep in mind:\n\n- `vp run <script>` is the equivalent of `pnpm run <script>`\n- `vp test` runs the built-in test command, while `vp run test` runs the `test` script from `package.json`\n- `vp install`, `vp add`, and `vp remove` delegate through the package manager declared by `packageManager`\n- `vp dev`, `vp build`, `vp preview`, `vp lint`, `vp fmt`, `vp check`, and `vp pack` replace the corresponding standalone tools\n- Prefer `vp check` for validation loops\n\nFinally, verify the migration by running: `vp install`, `vp check`, `vp test`, and `vp build`\n\nSummarize the migration at the end and report any manual follow-up still required.\n```\n\n## Tool-Specific Migrations\n\n### Vitest\n\nVitest is automatically migrated through `vp migrate`. If you are migrating manually, you have to update all the imports to `vite-plus/test` instead:\n\n```ts\n// before\nimport { describe, expect, it, vi } from 'vitest';\n\nconst { page } = await import('@vitest/browser/context');\n\n// after\nimport { describe, expect, it, vi } from 'vite-plus/test';\n\nconst { page } = await import('vite-plus/test/browser/context');\n```\n\n### tsdown\n\nIf your project uses a `tsdown.config.ts`, move its options into the `pack` block in `vite.config.ts`:\n\n```ts\n// before — tsdown.config.ts\nimport { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n  entry: ['src/index.ts'],\n  dts: true,\n  format: ['esm', 'cjs'],\n});\n\n// after — vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: {\n    entry: ['src/index.ts'],\n    dts: true,\n    format: ['esm', 'cjs'],\n  },\n});\n```\n\nAfter merging, delete `tsdown.config.ts`. See the [Pack guide](/guide/pack) for the full configuration reference.\n\n### lint-staged\n\nVite+ replaces lint-staged with its own `staged` block in `vite.config.ts`. Only the `staged` config format is supported. Standalone `.lintstagedrc` in non-JSON format and `lint-staged.config.*` are not migrated automatically.\n\nMove your lint-staged rules into the `staged` block:\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    '*.{js,ts,tsx,vue,svelte}': 'vp check --fix',\n  },\n});\n```\n\nAfter migrating, remove lint-staged from your dependencies and delete any lint-staged config files. See the [Commit hooks guide](/guide/commit-hooks) and [Staged config reference](/config/staged) for details.\n\n## Examples\n\n```bash\n# Migrate the current project\nvp migrate\n\n# Migrate a specific directory\nvp migrate my-app\n\n# Run without prompts\nvp migrate --no-interactive\n\n# Write agent and editor setup during migration\nvp migrate --agent claude --editor zed\n```\n"
  },
  {
    "path": "docs/guide/pack.md",
    "content": "# Pack\n\n`vp pack` builds libraries for production with [tsdown](https://tsdown.dev/guide/).\n\n## Overview\n\n`vp pack` builds libraries and standalone executables with tsdown. Use it for publishable packages and binary outputs. If you want to build a web application, use `vp build`. `vp pack` covers everything you need for building libraries out of the box, including declaration file generation, multiple output formats, source maps, and minification.\n\nFor more information about how tsdown works, see the official [tsdown guide](https://tsdown.dev/guide/).\n\n## Usage\n\n```bash\nvp pack\nvp pack src/index.ts --dts\nvp pack --watch\n```\n\n## Configuration\n\nPut packaging configuration directly in the `pack` block in `vite.config.ts` so all your configuration stays in one place. We do not recommend using `tsdown.config.ts` with Vite+.\n\nSee the [tsdown guide](https://tsdown.dev/guide/) and the [tsdown config file docs](https://tsdown.dev/options/config-file) to learn more about how to use and configure `vp pack`.\n\nUse it for:\n\n- [declaration files (`dts`)](https://tsdown.dev/options/dts)\n- [output formats](https://tsdown.dev/options/output-format)\n- [watch mode](https://tsdown.dev/options/watch-mode)\n- [standalone executables](https://tsdown.dev/options/exe#executable)\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: {\n    dts: true,\n    format: ['esm', 'cjs'],\n    sourcemap: true,\n  },\n});\n```\n\n## Standalone Executables\n\n`vp pack` can also build standalone executables through tsdown's experimental [`exe` option](https://tsdown.dev/options/exe#executable).\n\nUse this when you want to ship a CLI or other Node-based tool as a native executable that runs without requiring Node.js to be installed separately.\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  pack: {\n    entry: ['src/cli.ts'],\n    exe: true,\n  },\n});\n```\n\nSee the official [tsdown executable docs](https://tsdown.dev/options/exe#executable) for details about configuring custom file names, embedded assets, and cross-platform targets.\n"
  },
  {
    "path": "docs/guide/run.md",
    "content": "# Run\n\n`vp run` runs `package.json` scripts and tasks defined in `vite.config.ts`. It works like `pnpm run`, with caching, dependency ordering, and workspace-aware execution built in.\n\n## Overview\n\nUse `vp run` with existing `package.json` scripts:\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"build\": \"node compile-legacy-app.js\",\n    \"test\": \"jest\"\n  }\n}\n```\n\n`vp run build` executes the associated build script:\n\n```\n$ node compile-legacy-app.js\n\nbuilding legacy app for production...\n\n✓ built in 69s\n```\n\nUse `vp run` without a task name to use the interactive task runner:\n\n```\nSelect a task (↑/↓, Enter to run, Esc to clear):\n\n  › build: node compile-legacy-app.js\n    test: jest\n```\n\n## Caching\n\n`package.json` scripts are not cached by default. Use `--cache` to enable caching:\n\n```bash\nvp run --cache build\n```\n\n```\n$ node compile-legacy-app.js\n✓ built in 69s\n```\n\nIf nothing changes, the output is replayed from the cache on the next run:\n\n```\n$ node compile-legacy-app.js ✓ cache hit, replaying\n✓ built in 69s\n\n---\nvp run: cache hit, 69s saved.\n```\n\nIf an input changes, the task runs again:\n\n```\n$ node compile-legacy-app.js ✗ cache miss: 'legacy/index.js' modified, executing\n```\n\n## Task Definitions\n\nVite Task automatically tracks which files your command uses. You can define tasks directly in `vite.config.ts` to enable caching by default or control which files and environment variables affect cache behavior.\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  run: {\n    tasks: {\n      build: {\n        command: 'vp build',\n        dependsOn: ['lint'],\n        env: ['NODE_ENV'],\n      },\n      deploy: {\n        command: 'deploy-script --prod',\n        cache: false,\n        dependsOn: ['build', 'test'],\n      },\n    },\n  },\n});\n```\n\nIf you want to run an existing `package.json` script as-is, use `vp run <script>`. If you want task-level caching, dependencies, or environment/input controls, define a task with an explicit `command`. A task name can come from `vite.config.ts` or `package.json`, but not both.\n\n::: info\nTasks defined in `vite.config.ts` are cached by default. `package.json` scripts are not. See [When Is Caching Enabled?](/guide/cache#when-is-caching-enabled) for the full resolution order.\n:::\n\nSee [Run Config](/config/run) for the full `run` block reference.\n\n## Task Dependencies\n\nUse [`dependsOn`](#depends-on) to run tasks in the right order. Running `vp run deploy` with the config above runs `build` and `test` first. Dependencies can also target other packages in the same project with the `package#task` notation:\n\n```ts\ndependsOn: ['@my/core#build', '@my/utils#lint'];\n```\n\n## Running in a Workspace\n\nWith no package-selection flags, `vp run` runs the task in the package in your current working directory:\n\n```bash\ncd packages/app\nvp run build\n```\n\nYou can also target a package explicitly from anywhere:\n\n```bash\nvp run @my/app#build\n```\n\nWorkspace package ordering is based on the normal monorepo dependency graph declared in each package's `package.json`. In other words, when Vite+ talks about package dependencies, it means the regular `dependencies` relationships between workspace packages, not a separate task-runner-specific graph.\n\n### Recursive (`-r`)\n\nRun the task in every workspace package, in dependency order:\n\n```bash\nvp run -r build\n```\n\nThat dependency order comes from the workspace packages referenced through `package.json` dependencies.\n\n### Transitive (`-t`)\n\nRun the task in one package and all of its dependencies:\n\n```bash\nvp run -t @my/app#build\n```\n\nIf `@my/app` depends on `@my/utils`, which depends on `@my/core`, this runs all three in order. Vite+ resolves that chain from the normal workspace package dependencies declared in `package.json`.\n\n### Filter (`--filter`)\n\nSelect packages by name, directory, or glob pattern. The syntax matches pnpm's `--filter`:\n\n```bash\n# By name\nvp run --filter @my/app build\n\n# By glob\nvp run --filter \"@my/*\" build\n\n# By directory\nvp run --filter ./packages/app build\n\n# Include dependencies\nvp run --filter \"@my/app...\" build\n\n# Include dependents\nvp run --filter \"...@my/core\" build\n\n# Exclude packages\nvp run --filter \"@my/*\" --filter \"!@my/utils\" build\n```\n\nMultiple `--filter` flags are combined as a union. Exclusion filters are applied after all inclusions.\n\n### Workspace Root (`-w`)\n\nExplicitly run the task in the workspace root package:\n\n```bash\nvp run -w build\n```\n\n## Compound Commands\n\nCommands joined with `&&` are split into independent sub-tasks. Each sub-task is cached separately when [caching is enabled](/guide/cache#when-is-caching-enabled). This works for both `vite.config.ts` tasks and `package.json` scripts:\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"check\": \"vp lint && vp build\"\n  }\n}\n```\n\nNow, run `vp run --cache check`:\n\n```\n$ vp lint\nFound 0 warnings and 0 errors.\n\n$ vp build\n✓ built in 28ms\n\n---\nvp run: 0/2 cache hit (0%).\n```\n\nEach sub-task has its own cache entry. If only `.ts` files changed but lint still passes, only `vp build` runs again the next time `vp run --cache check` is called:\n\n```\n$ vp lint ✓ cache hit, replaying\n$ vp build ✗ cache miss: 'src/index.ts' modified, executing\n✓ built in 30ms\n\n---\nvp run: 1/2 cache hit (50%), 120ms saved.\n```\n\n### Nested `vp run`\n\nWhen a command contains `vp run`, Vite Task inlines it as separate tasks instead of spawning a nested process. Each sub-task is cached independently and output stays flat:\n\n```json [package.json]\n{\n  \"scripts\": {\n    \"ci\": \"vp run lint && vp run test && vp run build\"\n  }\n}\n```\n\nRunning `vp run ci` expands into three tasks:\n\n```mermaid\ngraph LR\n  lint --> test --> build\n```\n\nFlags also work inside nested scripts. For example, `vp run -r build` inside a script expands into individual build tasks for every package.\n\n::: info\nA common monorepo pattern is a root script that runs a task recursively:\n\n```json [package.json (root)]\n{\n  \"scripts\": {\n    \"build\": \"vp run -r build\"\n  }\n}\n```\n\nThis creates a potential recursion: root's `build` -> `vp run -r build` -> includes root's `build` -> ...\n\nVite Task detects this and prunes the self-reference automatically, so other packages build normally.\n:::\n\n## Execution Summary\n\nUse `-v` to show a detailed execution summary:\n\n```bash\nvp run -r -v build\n```\n\n```\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    Vite+ Task Runner • Execution Summary\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nStatistics:   3 tasks • 3 cache hits • 0 cache misses\nPerformance:  100% cache hit rate, 468ms saved in total\n\nTask Details:\n────────────────────────────────────────────────\n  [1] @my/core#build: ~/packages/core$ vp build ✓\n      → Cache hit - output replayed - 200ms saved\n  ·······················································\n  [2] @my/utils#build: ~/packages/utils$ vp build ✓\n      → Cache hit - output replayed - 150ms saved\n  ·······················································\n  [3] @my/app#build: ~/packages/app$ vp build ✓\n      → Cache hit - output replayed - 118ms saved\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n```\n\nUse `--last-details` to show the summary from the last run without running tasks again:\n\n```bash\nvp run --last-details\n```\n\n## Additional Arguments\n\nArguments after the task name are passed through to the task command:\n\n```bash\nvp run test --reporter verbose\n```\n"
  },
  {
    "path": "docs/guide/test.md",
    "content": "# Test\n\n`vp test` runs tests with [Vitest](https://vitest.dev).\n\n## Overview\n\n`vp test` is built on [Vitest](https://vitest.dev/), so you get a Vite-native test runner that reuses your Vite config and plugins, supports Jest-style expectations, snapshots, and coverage, and handles modern ESM, TypeScript, and JSX projects cleanly.\n\n## Usage\n\n```bash\nvp test\nvp test watch\nvp test run --coverage\n```\n\n::: info\nUnlike Vitest on its own, `vp test` does not stay in watch mode by default. Use `vp test` when you want a normal test run, and use `vp test watch` when you want to jump into watch mode.\n:::\n\n## Configuration\n\nPut test configuration directly in the `test` block in `vite.config.ts` so all your configuration stays in one place. We do not recommend using `vitest.config.ts` with Vite+.\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  test: {\n    include: ['src/**/*.test.ts'],\n  },\n});\n```\n\nFor the full Vitest configuration reference, see the [Vitest config docs](https://vitest.dev/config/).\n"
  },
  {
    "path": "docs/guide/troubleshooting.md",
    "content": "# Troubleshooting\n\nUse this page when something in Vite+ is not behaving the way you expect.\n\n::: warning\nVite+ is still in alpha. We are making frequent changes, adding features quickly, and we want feedback to help make it great.\n:::\n\n## Supported Tool Versions\n\nVite+ expects modern upstream tool versions.\n\n- Vite 8 or newer\n- Vitest 4.1 or newer\n\nIf you are migrating an existing project and it still depends on older Vite or Vitest versions, upgrade those first before adopting Vite+.\n\n## `vp check` does not run type-aware lint rules or type checks\n\n- Confirm that `lint.options.typeAware` and `lint.options.typeCheck` are enabled in `vite.config.ts`\n- Check whether your `tsconfig.json` uses `compilerOptions.baseUrl`\n\nThe Oxlint type checker path powered by `tsgolint` does not support `baseUrl`, so Vite+ skips `typeAware` and `typeCheck` when that setting is present.\n\n## `vp build` does not run my build script\n\nUnlike package managers, built-in commands cannot be overwritten. If you are trying to run a `package.json` script use `vp run build` instead.\n\nFor example:\n\n- `vp build` always runs the built-in Vite build\n- `vp test` always runs the built-in Vitest command\n- `vp run build` and `vp run test` run `package.json` scripts instead\n\n::: info\nYou can also run custom tasks defined in `vite.config.ts` and migrate away from `package.json` scripts entirely.\n:::\n\n## Staged Checks and Commit Hooks\n\nIf `vp staged` fails or your pre-commit hook does not run:\n\n- make sure `vite.config.ts` contains a `staged` block\n- run `vp config` to install hooks\n- check whether hook installation was skipped intentionally through `VITE_GIT_HOOKS=0`\n\nA minimal staged config looks like this:\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    '*': 'vp check --fix',\n  },\n});\n```\n\n## Asking for Help\n\nIf you are stuck, please reach out:\n\n- [Discord](https://discord.gg/cAnsqHh5PX) for real-time discussion and troubleshooting help\n- [GitHub](https://github.com/voidzero-dev/vite-plus) for issues, discussions, and bug reports\n\nWhen reporting a problem, please include:\n\n- The full output of `vp env current` and `vp --version`\n- The package manager used by the project\n- The exact steps needed to reproduce the problem and your `vite.config.ts`\n- A minimal reproduction repository or runnable sandbox\n"
  },
  {
    "path": "docs/guide/upgrade.md",
    "content": "# Upgrading Vite+\n\nUse `vp upgrade` to update the global `vp` binary, and use Vite+'s package management commands to update the local `vite-plus` package in a project.\n\n## Overview\n\nThere are two parts to upgrading Vite+:\n\n- The global `vp` command installed on your machine\n- The local `vite-plus` package used by an individual project\n\nYou can upgrade both of them independently.\n\n## Global `vp`\n\n```bash\nvp upgrade\n```\n\n## Local `vite-plus`\n\nUpdate the project dependency with the package manager commands in Vite+:\n\n```bash\nvp update vite-plus\n```\n\nYou can also use `vp add vite-plus@latest` if you want to move the dependency explicitly to the latest version.\n"
  },
  {
    "path": "docs/guide/vpx.md",
    "content": "# Running Binaries\n\nUse `vpx`, `vp exec`, and `vp dlx` to run binaries without switching between local installs, downloaded packages, and project-specific tools.\n\n## Overview\n\n`vpx` executes a command from a local or remote npm package. It can run a package that is already available locally, download a package on demand, or target an explicit package version.\n\nUse the other binary commands when you need stricter control:\n\n- `vpx` resolves a package binary locally first and can download it when needed\n- `vp exec` runs a binary from the current project's `node_modules/.bin`\n- `vp dlx` runs a package binary without adding it as a dependency\n\n## `vpx`\n\nUse `vpx` for running any local or remote binary:\n\n```bash\nvpx <pkg[@version]> [args...]\n```\n\n### Options\n\n- `-p, --package <name>` installs one or more packages before running the command\n- `-c, --shell-mode` executes the command inside a shell\n- `-s, --silent` suppresses Vite+ output and only shows the command output\n\n### Examples\n\n```bash\nvpx eslint .\nvpx create-vue my-app\nvpx typescript@5.5.4 tsc --version\nvpx -p cowsay -c 'echo \"hi\" | cowsay'\n```\n\n## `vp exec`\n\nUse `vp exec` when the binary must come from the current project, for example a binary from a dependency installed in `node_modules/.bin`.\n\n```bash\nvp exec <command> [args...]\n```\n\nExamples:\n\n```bash\nvp exec eslint .\nvp exec tsc --noEmit\n```\n\n## `vp dlx`\n\nUse `vp dlx` for one-off package execution without adding the package to your project dependencies.\n\n```bash\nvp dlx <package> [args...]\n```\n\nExamples:\n\n```bash\nvp dlx create-vite\nvp dlx typescript tsc --version\n```\n"
  },
  {
    "path": "docs/guide/why.md",
    "content": "# Why Vite+?\n\nWorking in the JavaScript ecosystem today, developers need a runtime such as Node.js, a package manager like pnpm, a dev server, a linter, a formatter, a test runner, a bundler, a task runner, and a growing number of config files.\n\nVite showed that frontend tooling could become dramatically faster by rethinking the architecture instead of accepting the status quo. Vite+ applies that same idea to the rest of the local development workflow, and unifies them all into a single package that speeds up and simplifies development.\n\n## The Problem Vite+ is Solving\n\nThe JavaScript tooling ecosystem has seen its fair share of fragmentation and churn. Web apps keep getting larger, and as a result tooling performance, complexity, and inconsistencies have become real bottlenecks as projects grow.\n\nThese bottlenecks are amplified in organizations with multiple teams, each using a different tooling stack. Dependency management, build infrastructure, and code quality become fragmented responsibilities, handled team by team and often not owned as a priority by anyone. As a result, dependencies drift out of sync, builds get slower, and code quality declines. Fixing those problems later requires significantly more effort, slows everyone down, and pulls teams away from shipping product.\n\n## What's Included in Vite+\n\nVite+ brings the tools needed for modern web development together into a single, integrated toolchain. Instead of assembling and maintaining a custom toolchain, Vite+ provides a consistent entry point that manages the runtime, dependencies, development server, code quality checks, testing, and builds in one place.\n\n- **[Vite](https://vite.dev/)** and **[Rolldown](https://rolldown.rs/)** for development and application builds\n- **[Vitest](https://vitest.dev/)** for testing\n- **[Oxlint](https://oxc.rs/docs/guide/usage/linter.html)** and **[Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html)** for linting and formatting\n- **[tsdown](https://tsdown.dev/)** for library builds or standalone executables\n- **[Vite Task](https://github.com/voidzero-dev/vite-task)** for task orchestration\n\nIn practice, this means developers interact with one consistent workflow: `vp dev`, `vp check`, `vp test`, and `vp build`.\n\nThis unified toolchain reduces configuration overhead, improves performance, and makes it easier for teams to maintain consistent tooling across projects.\n\n## Fast and Scalable by Default\n\nVite+ is built on top of modern tooling such as Vite, Rolldown, Oxc, Vitest, and Vite Task to keep your projects fast and scalable as your codebase grows. By using Rust, we can speed up common tasks by [10× or sometimes even by 100×](https://voidzero.dev/posts/announcing-vite-plus-alpha#performance-scale). However, many Rust-based toolchains are incompatible with existing tools, or aren't extensible using JavaScript.\n\nVite+ bridges Rust to JavaScript via [NAPI-RS](https://napi.rs/) which allows it to provide a familiar, easy-to-configure, and extensible interface in JavaScript with a great ecosystem-compatible developer experience.\n\nUnifying the toolchain has performance benefits beyond just using faster tools on their own. For example, many developers set up their linter with \"type aware\" tools, requiring a full-typecheck to be run during the linting stage. With `vp check` you can format, lint, and type-check your code all in a single pass, speeding up static checks by 2× compared to running type-aware lint rules and type-checks separately.\n\n## Fully Open Source\n\nVite+ is fully open source and not a new framework or locked-down platform. Vite+ integrates with the existing Vite ecosystem and the frameworks built on top of it, including React, Vue, Svelte, and others. It can use pnpm, npm, or Yarn, and manages the Node.js runtime for you.\n\nWe always welcome contributions from the community. See our [Contributing Guidelines](https://github.com/voidzero-dev/vite-plus/blob/main/CONTRIBUTING.md) to get involved.\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\nhome: true\nlayout: home\ntheme: light\ntitleTemplate: The Unified Toolchain for the Web\n---\n\n<script setup>\nimport Home from '@layouts/Home.vue'\n</script>\n\n<Home />\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"name\": \"vite-plus-docs\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vitepress dev\",\n    \"build\": \"cp ../packages/cli/install.sh ../packages/cli/install.ps1 public/ && vitepress build\",\n    \"preview\": \"vitepress preview\"\n  },\n  \"dependencies\": {\n    \"@iconify/vue\": \"^5.0.0\",\n    \"mermaid\": \"^11.13.0\",\n    \"reka-ui\": \"^2.7.0\",\n    \"typewriter-effect\": \"^2.22.0\",\n    \"vitepress-plugin-mermaid\": \"^2.0.17\",\n    \"vue\": \"^3.5.27\",\n    \"vue3-carousel\": \"^0.16.0\"\n  },\n  \"devDependencies\": {\n    \"@voidzero-dev/vitepress-theme\": \"4.8.3\",\n    \"oxc-minify\": \"^0.120.0\",\n    \"tailwindcss\": \"^4.1.18\",\n    \"vitepress\": \"2.0.0-alpha.15\"\n  }\n}\n"
  },
  {
    "path": "docs/pnpm-workspace.yaml",
    "content": "packages:\n  - .\n\npeerDependencyRules:\n  allowAny:\n    - vitepress\n"
  },
  {
    "path": "docs/public/_redirects",
    "content": "/hello https://tally.so/r/nGWebL"
  },
  {
    "path": "ecosystem-ci/clone.ts",
    "content": "import { execSync } from 'node:child_process';\nimport { existsSync, mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { ecosystemCiDir } from './paths.ts';\nimport repos from './repo.json' with { type: 'json' };\n\nconst cwd = import.meta.dirname;\n\nfunction exec(cmd: string, execCwd: string = cwd): string {\n  return execSync(cmd, { cwd: execCwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();\n}\n\nfunction getRemoteUrl(dir: string): string | null {\n  try {\n    return exec('git remote get-url origin', dir);\n  } catch {\n    return null;\n  }\n}\n\nfunction normalizeGitUrl(url: string): string {\n  // Convert git@github.com:owner/repo.git to github.com/owner/repo\n  // Convert https://github.com/owner/repo.git to github.com/owner/repo\n  return url\n    .replace(/^git@([^:]+):/, '$1/')\n    .replace(/^https?:\\/\\//, '')\n    .replace(/\\.git$/, '');\n}\n\nfunction isSameRepo(url1: string, url2: string): boolean {\n  return normalizeGitUrl(url1) === normalizeGitUrl(url2);\n}\n\nfunction getCurrentHash(dir: string): string | null {\n  try {\n    return exec('git rev-parse HEAD', dir);\n  } catch {\n    return null;\n  }\n}\n\nfunction cloneRepo(repoUrl: string, targetDir: string): void {\n  console.info(`Cloning ${repoUrl}…`);\n  exec(`git clone --depth 1 ${repoUrl} ${targetDir}`);\n}\n\nfunction checkoutHash(dir: string, hash: string): void {\n  console.info(`Checking out ${hash.slice(0, 7)}…`);\n  exec(`git fetch --depth 1 origin ${hash}`, dir);\n  exec(`git checkout ${hash}`, dir);\n}\n\nfunction cloneProject(repoName: string): void {\n  const repo = repos[repoName as keyof typeof repos];\n  if (!repo) {\n    console.error(`Project ${repoName} is not defined in repo.json`);\n    process.exit(1);\n  }\n\n  const targetDir = join(ecosystemCiDir, repoName);\n\n  if (existsSync(targetDir)) {\n    console.info(`Directory ${repoName} exists, validating…`);\n\n    const remoteUrl = getRemoteUrl(targetDir);\n    if (!remoteUrl) {\n      console.error(`  ✗ ${repoName} is not a git repository`);\n      process.exit(1);\n    }\n\n    if (!isSameRepo(remoteUrl, repo.repository)) {\n      console.error(`  ✗ Remote mismatch: expected ${repo.repository}, got ${remoteUrl}`);\n      process.exit(1);\n    }\n\n    console.info(`  ✓ Remote matches`);\n\n    const currentHash = getCurrentHash(targetDir);\n    if (currentHash === repo.hash) {\n      console.info(`  ✓ Already at correct commit ${repo.hash.slice(0, 7)}`);\n    } else {\n      console.info(`  → Current: ${currentHash?.slice(0, 7)}, expected: ${repo.hash.slice(0, 7)}`);\n      checkoutHash(targetDir, repo.hash);\n      console.info(`  ✓ Checked out ${repo.hash.slice(0, 7)}`);\n    }\n  } else {\n    cloneRepo(repo.repository, targetDir);\n    checkoutHash(targetDir, repo.hash);\n    console.info(`✓ Cloned and checked out ${repo.hash.slice(0, 7)}`);\n  }\n}\n\n// Ensure the directory exists\nmkdirSync(ecosystemCiDir, { recursive: true });\n\nconst project = process.argv[2];\n\nif (project) {\n  // Clone a single project\n  console.info(`Cloning ${project} to ${ecosystemCiDir}\\n`);\n  cloneProject(project);\n} else {\n  // Clone all projects\n  console.info(`Cloning all ecosystem-ci projects to ${ecosystemCiDir}\\n`);\n  for (const repoName of Object.keys(repos)) {\n    cloneProject(repoName);\n    console.info('');\n  }\n}\n\nconsole.info('Done!');\n"
  },
  {
    "path": "ecosystem-ci/patch-project.ts",
    "content": "import { execSync } from 'node:child_process';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport { join } from 'node:path';\n\nimport { ecosystemCiDir, tgzDir } from './paths.ts';\nimport repos from './repo.json' with { type: 'json' };\n\nconst projects = Object.keys(repos);\n\nconst project = process.argv[2];\n\nif (!projects.includes(project)) {\n  console.error(`Project ${project} is not defined in repo.json`);\n  process.exit(1);\n}\n\nconst repoRoot = join(ecosystemCiDir, project);\nconst repoConfig = repos[project as keyof typeof repos];\nconst directory = 'directory' in repoConfig ? repoConfig.directory : undefined;\nconst cwd = directory ? join(repoRoot, directory) : repoRoot;\n// run vp migrate\nconst cli = process.env.VITE_PLUS_CLI_BIN ?? 'vp';\n\nif (project === 'rollipop') {\n  const oxfmtrc = await readFile(join(repoRoot, '.oxfmtrc.json'), 'utf-8');\n  await writeFile(\n    join(repoRoot, '.oxfmtrc.json'),\n    oxfmtrc.replace('      [\"ts-equals-import\"],\\n', ''),\n    'utf-8',\n  );\n}\n\n// Projects that already use vite-plus need VITE_PLUS_FORCE_MIGRATE=1 so\n// vp migrate runs full dependency rewriting instead of skipping.\nconst forceFreshMigration = 'forceFreshMigration' in repoConfig && repoConfig.forceFreshMigration;\n\nexecSync(`${cli} migrate --no-agent --no-interactive`, {\n  cwd,\n  stdio: 'inherit',\n  env: {\n    ...process.env,\n    ...(forceFreshMigration ? { VITE_PLUS_FORCE_MIGRATE: '1' } : {}),\n    VITE_PLUS_OVERRIDE_PACKAGES: JSON.stringify({\n      vite: `file:${tgzDir}/voidzero-dev-vite-plus-core-0.0.0.tgz`,\n      vitest: `file:${tgzDir}/voidzero-dev-vite-plus-test-0.0.0.tgz`,\n      '@voidzero-dev/vite-plus-core': `file:${tgzDir}/voidzero-dev-vite-plus-core-0.0.0.tgz`,\n      '@voidzero-dev/vite-plus-test': `file:${tgzDir}/voidzero-dev-vite-plus-test-0.0.0.tgz`,\n    }),\n    VITE_PLUS_VERSION: `file:${tgzDir}/vite-plus-0.0.0.tgz`,\n  },\n});\n"
  },
  {
    "path": "ecosystem-ci/paths.ts",
    "content": "import { tmpdir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst projectDir = dirname(fileURLToPath(import.meta.url));\n\n// Use RUNNER_TEMP in GitHub Actions, otherwise use system temp directory\nconst tempBase = process.env.RUNNER_TEMP ?? tmpdir();\n\nexport const ecosystemCiDir = join(tempBase, 'vite-plus-ecosystem-ci');\n\n// tgz path: always use local tmp/tgz\nexport const tgzDir = join(projectDir, '..', 'tmp', 'tgz');\n"
  },
  {
    "path": "ecosystem-ci/repo.json",
    "content": "{\n  \"dify\": {\n    \"repository\": \"https://github.com/langgenius/dify.git\",\n    \"branch\": \"main\",\n    \"hash\": \"356a156f365a3c762f29303176defb28cbed3710\",\n    \"directory\": \"web\"\n  },\n  \"skeleton\": {\n    \"repository\": \"https://github.com/skeletonlabs/skeleton.git\",\n    \"branch\": \"main\",\n    \"hash\": \"6d57f29b823275c6e3fb267c6834da5d39558fb6\"\n  },\n  \"vibe-dashboard\": {\n    \"repository\": \"https://github.com/voidzero-dev/vibe-dashboard.git\",\n    \"branch\": \"main\",\n    \"hash\": \"158e4a0c3d8a1801e330300a5deba4506fd5dfb9\",\n    \"forceFreshMigration\": true\n  },\n  \"rollipop\": {\n    \"repository\": \"https://github.com/leegeunhyeok/rollipop.git\",\n    \"branch\": \"main\",\n    \"hash\": \"9beb8dd8fb70ef298b3a18703a831d6d4d3c01a1\"\n  },\n  \"frm-stack\": {\n    \"repository\": \"https://github.com/Nikola-Milovic/frm-stack.git\",\n    \"branch\": \"main\",\n    \"hash\": \"e9e344125d8476ed6f34880036c0b1aef8dc0bb5\"\n  },\n  \"vue-mini\": {\n    \"repository\": \"https://github.com/vue-mini/vue-mini.git\",\n    \"branch\": \"master\",\n    \"hash\": \"23df6ba49e29d3ea909ef55874f59b973916d177\"\n  },\n  \"vite-plugin-react\": {\n    \"repository\": \"https://github.com/vitejs/vite-plugin-react.git\",\n    \"branch\": \"main\",\n    \"hash\": \"0d3912b73d3aa1dc8f64619c82b3dacb0769e49e\"\n  },\n  \"vitepress\": {\n    \"repository\": \"https://github.com/vuejs/vitepress.git\",\n    \"branch\": \"feat/vite-8\",\n    \"hash\": \"9330f228861623c71f2598271d4e79c9c53f2c08\"\n  },\n  \"tanstack-start-helloworld\": {\n    \"repository\": \"https://github.com/fengmk2/tanstack-start-helloworld.git\",\n    \"branch\": \"main\",\n    \"hash\": \"09bafe177bbcd1e3108c441a67601ae6380ad352\"\n  },\n  \"oxlint-plugin-complexity\": {\n    \"repository\": \"https://github.com/itaymendel/oxlint-plugin-complexity.git\",\n    \"branch\": \"main\",\n    \"hash\": \"439d7df1a5d594458e95071612c5edd93ff736a5\"\n  },\n  \"vite-vue-vercel\": {\n    \"repository\": \"https://github.com/fengmk2/vite-vue-vercel.git\",\n    \"branch\": \"main\",\n    \"hash\": \"f2bf9fc40880c6a80f5d89bff70641c2eeaf77ef\"\n  },\n  \"viteplus-ws-repro\": {\n    \"repository\": \"https://github.com/Charles5277/viteplus-ws-repro.git\",\n    \"branch\": \"main\",\n    \"hash\": \"451925ad7c07750a23de1d6ed454825d0eb14092\",\n    \"forceFreshMigration\": true\n  },\n  \"vp-config\": {\n    \"repository\": \"https://github.com/kazupon/vp-config.git\",\n    \"branch\": \"main\",\n    \"hash\": \"b58c48d71a17c25dec71a003535e6312791ce2aa\",\n    \"forceFreshMigration\": true\n  },\n  \"vinext\": {\n    \"repository\": \"https://github.com/cloudflare/vinext.git\",\n    \"branch\": \"main\",\n    \"hash\": \"f78dd2b39f5b02242417e0a684b1f2f55d3dbdff\",\n    \"forceFreshMigration\": true\n  },\n  \"reactive-resume\": {\n    \"repository\": \"https://github.com/amruthpillai/reactive-resume.git\",\n    \"branch\": \"main\",\n    \"hash\": \"f4e2d05f25539ba64a97368f6e35b33b2a7bed05\",\n    \"forceFreshMigration\": true\n  },\n  \"yaak\": {\n    \"repository\": \"https://github.com/mountain-loop/yaak.git\",\n    \"branch\": \"main\",\n    \"hash\": \"b4a1c418bb3f923858dd55729f585e189327a038\",\n    \"forceFreshMigration\": true\n  },\n  \"npmx.dev\": {\n    \"repository\": \"https://github.com/npmx-dev/npmx.dev.git\",\n    \"branch\": \"main\",\n    \"hash\": \"230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d\",\n    \"forceFreshMigration\": true\n  },\n  \"vite-plus-jest-dom-repro\": {\n    \"repository\": \"https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git\",\n    \"branch\": \"master\",\n    \"hash\": \"01bd9ce1ac66ee3c21ed8a7f14311317d87fb999\",\n    \"forceFreshMigration\": true\n  }\n}\n"
  },
  {
    "path": "ecosystem-ci/verify-install.ts",
    "content": "import { createRequire } from 'node:module';\n\nconst require = createRequire(`${process.cwd()}/`);\n\nconst expectedVersion = '0.0.0';\n\ntry {\n  const pkg = require('vite-plus/package.json') as { version: string; name: string };\n  if (pkg.version !== expectedVersion) {\n    console.error(`✗ vite-plus: expected version ${expectedVersion}, got ${pkg.version}`);\n    process.exit(1);\n  }\n  console.log(`✓ vite-plus@${pkg.version}`);\n} catch {\n  console.error('✗ vite-plus: not installed');\n  process.exit(1);\n}\n"
  },
  {
    "path": "justfile",
    "content": "#!/usr/bin/env -S just --justfile\n\nset windows-shell := [\"powershell.exe\", \"-NoLogo\", \"-Command\"]\nset shell := [\"bash\", \"-cu\"]\n\n_default:\n  @just --list -u\n\nalias r := ready\n\n[unix]\n_clean_dist:\n  rm -rf packages/*/dist\n\n[windows]\n_clean_dist:\n  Remove-Item -Path 'packages/*/dist' -Recurse -Force -ErrorAction SilentlyContinue\n\ninit: _clean_dist\n  cargo binstall watchexec-cli cargo-insta typos-cli cargo-shear dprint taplo-cli -y\n  node packages/tools/src/index.ts sync-remote\n  pnpm install\n  pnpm -C docs install\n\nbuild:\n  pnpm install\n  pnpm --filter @rolldown/pluginutils build\n  pnpm --filter rolldown build-binding:release\n  pnpm --filter rolldown build-node\n  pnpm --filter vite build-types\n  pnpm --filter=@voidzero-dev/vite-plus-core build\n  pnpm --filter=@voidzero-dev/vite-plus-test build\n  pnpm --filter=@voidzero-dev/vite-plus-prompts build\n  pnpm --filter=vite-plus build\n\nready:\n  git diff --exit-code --quiet\n  typos\n  just fmt\n  just check\n  just test\n  just lint\n  just doc\n\nwatch *args='':\n  watchexec --no-vcs-ignore {{args}}\n\nfmt:\n  cargo shear --fix\n  cargo fmt --all\n  pnpm fmt\n\ncheck:\n  cargo check --workspace --all-features --all-targets --locked\n\nwatch-check:\n  just watch \"'cargo check; cargo clippy'\"\n\ntest:\n  cargo test\n\nlint:\n  cargo clippy --workspace --all-targets --all-features -- --deny warnings\n\n[unix]\ndoc:\n  RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --document-private-items\n\n[windows]\ndoc:\n  $Env:RUSTDOCFLAGS='-D warnings'; cargo doc --no-deps --document-private-items\n"
  },
  {
    "path": "netlify.toml",
    "content": "[build]\nbase = \"docs/\"\ncommand = \"pnpm build\"\npublish = \".vitepress/dist\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"vite-plus-monorepo\",\n  \"private\": true,\n  \"license\": \"MIT\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build\": \"pnpm -F @voidzero-dev/* -F vite-plus build\",\n    \"bootstrap-cli\": \"pnpm build && cargo build -p vite_global_cli -p vite_trampoline --release && pnpm install-global-cli\",\n    \"bootstrap-cli:ci\": \"pnpm install-global-cli\",\n    \"install-global-cli\": \"tool install-global-cli\",\n    \"tsgo\": \"tsgo -b tsconfig.json\",\n    \"lint\": \"vp lint --type-aware --type-check --threads 4\",\n    \"test\": \"vp test run && pnpm -r snap-test\",\n    \"fmt\": \"vp fmt\",\n    \"test:unit\": \"vp test run\",\n    \"docs:dev\": \"pnpm -C docs dev\",\n    \"docs:build\": \"pnpm -C docs build\",\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"@napi-rs/cli\": \"catalog:\",\n    \"@oxc-node/cli\": \"catalog:\",\n    \"@oxc-node/core\": \"catalog:\",\n    \"@types/node\": \"catalog:\",\n    \"@typescript/native-preview\": \"catalog:\",\n    \"@voidzero-dev/vite-plus-tools\": \"workspace:*\",\n    \"bingo\": \"catalog:\",\n    \"husky\": \"catalog:\",\n    \"lint-staged\": \"catalog:\",\n    \"oxfmt\": \"catalog:\",\n    \"oxlint\": \"catalog:\",\n    \"playwright\": \"catalog:\",\n    \"typescript\": \"catalog:\",\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"workspace:*\",\n    \"vitest\": \"catalog:\",\n    \"zod\": \"catalog:\"\n  },\n  \"lint-staged\": {\n    \"*.@(js|ts|tsx|md|yaml|yml)\": \"vp check --fix\",\n    \"*.rs\": \"cargo fmt --\"\n  },\n  \"engines\": {\n    \"node\": \">=22.18.0\"\n  },\n  \"packageManager\": \"pnpm@10.28.0\"\n}\n"
  },
  {
    "path": "packages/cli/.gitignore",
    "content": "/npm\n/artifacts\n/LICENSE\n/skills/vite-plus/docs\n"
  },
  {
    "path": "packages/cli/AGENTS.md",
    "content": "<!--VITE PLUS START-->\n\n# Using Vite+, the Unified Toolchain for the Web\n\nThis project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, but it invokes Vite through `vp dev` and `vp build`.\n\n## Vite+ Workflow\n\n`vp` is a global binary that handles the full development lifecycle. Run `vp help` to print a list of commands and `vp <command> --help` for information about a specific command.\n\n### Start\n\n- create - Create a new project from a template\n- migrate - Migrate an existing project to Vite+\n- config - Configure hooks and agent integration\n- staged - Run linters on staged files\n- install (`i`) - Install dependencies\n- env - Manage Node.js versions\n\n### Develop\n\n- dev - Run the development server\n- check - Run format, lint, and TypeScript type checks\n- lint - Lint code\n- fmt - Format code\n- test - Run tests\n\n### Execute\n\n- run - Run monorepo tasks\n- exec - Execute a command from local `node_modules/.bin`\n- dlx - Execute a package binary without installing it as a dependency\n- cache - Manage the task cache\n\n### Build\n\n- build - Build for production\n- pack - Build libraries\n- preview - Preview production build\n\n### Manage Dependencies\n\nVite+ automatically detects and wraps the underlying package manager such as pnpm, npm, or Yarn through the `packageManager` field in `package.json` or package manager-specific lockfiles.\n\n- add - Add packages to dependencies\n- remove (`rm`, `un`, `uninstall`) - Remove packages from dependencies\n- update (`up`) - Update packages to latest versions\n- dedupe - Deduplicate dependencies\n- outdated - Check for outdated packages\n- list (`ls`) - List installed packages\n- why (`explain`) - Show why a package is installed\n- info (`view`, `show`) - View package information from the registry\n- link (`ln`) / unlink - Manage local package links\n- pm - Forward a command to the package manager\n\n### Maintain\n\n- upgrade - Update `vp` itself to the latest version\n\nThese commands map to their corresponding tools. For example, `vp dev --port 3000` runs Vite's dev server and works the same as Vite. `vp test` runs JavaScript tests through the bundled Vitest. The version of all tools can be checked using `vp --version`. This is useful when researching documentation, features, and bugs.\n\n## Common Pitfalls\n\n- **Using the package manager directly:** Do not use pnpm, npm, or Yarn directly. Vite+ can handle all package manager operations.\n- **Always use Vite commands to run tools:** Don't attempt to run `vp vitest` or `vp oxlint`. They do not exist. Use `vp test` and `vp lint` instead.\n- **Running scripts:** Vite+ built-in commands (`vp dev`, `vp build`, `vp test`, etc.) always run the Vite+ built-in tool, not any `package.json` script of the same name. To run a custom script that shares a name with a built-in command, use `vp run <script>`. For example, if you have a custom `dev` script that runs multiple services concurrently, run it with `vp run dev`, not `vp dev` (which always starts Vite's dev server).\n- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly:** Vite+ wraps these tools. They must not be installed directly. You cannot upgrade these tools by installing their latest versions. Always use Vite+ commands.\n- **Use Vite+ wrappers for one-off binaries:** Use `vp dlx` instead of package-manager-specific `dlx`/`npx` commands.\n- **Import JavaScript modules from `vite-plus`:** Instead of importing from `vite` or `vitest`, all modules should be imported from the project's `vite-plus` dependency. For example, `import { defineConfig } from 'vite-plus';` or `import { expect, test, vi } from 'vite-plus/test';`. You must not install `vitest` to import test utilities.\n- **Type-Aware Linting:** There is no need to install `oxlint-tsgolint`, `vp lint --type-aware` works out of the box.\n\n## CI Integration\n\nFor GitHub Actions, consider using [`voidzero-dev/setup-vp`](https://github.com/voidzero-dev/setup-vp) to replace separate `actions/setup-node`, package-manager setup, cache, and install steps with a single action.\n\n```yaml\n- uses: voidzero-dev/setup-vp@v1\n  with:\n    cache: true\n- run: vp check\n- run: vp test\n```\n\n## Review Checklist for Agents\n\n- [ ] Run `vp install` after pulling remote changes and before getting started.\n- [ ] Run `vp check` and `vp test` to validate changes.\n<!--VITE PLUS END-->\n"
  },
  {
    "path": "packages/cli/BUNDLING.md",
    "content": "# CLI Package Build Architecture\n\nThis document explains how `vite-plus` is built and how it re-exports from both the core and test packages to serve as a drop-in replacement for `vite`.\n\n## Overview\n\nThe CLI package uses a **4-step build process**:\n\n1. **TypeScript Compilation** - Compile TypeScript source to JavaScript\n2. **NAPI Binding Build** - Compile Rust code to native Node.js bindings\n3. **Core Package Export Sync** - Re-export `@voidzero-dev/vite-plus-core` under `./client`, `./types/*`, etc.\n4. **Test Package Export Sync** - Re-export `@voidzero-dev/vite-plus-test` under `./test/*`\n\nThis architecture allows users to import everything from a single package (`vite-plus`) as a drop-in replacement for `vite`, without needing to know about the separate core and test packages.\n\n## Build Steps\n\n### Step 1: TypeScript Compilation (`buildCli`)\n\nCompiles TypeScript source files using the TypeScript compiler API:\n\n```typescript\nconst program = createProgram({\n  rootNames: fileNames,\n  options,\n  host,\n});\nprogram.emit();\n```\n\n**Input**: `src/*.ts` files\n**Output**: `dist/*.js`, `dist/*.d.ts`\n\n### Step 2: NAPI Binding Build (`buildNapiBinding`)\n\nBuilds native Rust bindings using `@napi-rs/cli`:\n\n```typescript\nconst cli = new NapiCli();\nawait cli.build({\n  packageJsonPath: '../package.json',\n  cwd: 'binding',\n  platform: true,\n  release: process.env.VITE_PLUS_CLI_DEBUG !== '1',\n  esm: true,\n});\n```\n\n**Input**: `binding/*.rs` (Rust source)\n**Output**: `binding/*.node` (platform-specific binaries)\n\nThe build generates platform-specific native binaries and formats the generated JavaScript wrapper with `oxfmt`.\n\n### Step 3: Core Package Export Sync (`syncCorePackageExports`)\n\nCreates shim files that re-export from `@voidzero-dev/vite-plus-core`, enabling this package to be a drop-in replacement for upstream `vite`. This is critical for compatibility with existing Vite plugins and configurations.\n\n**Prerequisites**: The core package must be built first (its `dist/vite/` directory must exist). See [Core Package Bundling](../core/BUNDLING.md) for details on how the core package bundles vite, rolldown, and tsdown.\n\n**Export paths created**:\n\n| Export Path          | Type       | Description                                                                             |\n| -------------------- | ---------- | --------------------------------------------------------------------------------------- |\n| `./client`           | Types only | Triple-slash reference for ambient type declarations (CSS modules, asset imports, etc.) |\n| `./module-runner`    | JS + Types | Re-exports the Vite module runner for SSR/environments                                  |\n| `./internal`         | JS + Types | Re-exports internal Vite APIs                                                           |\n| `./dist/client/*`    | JS         | Client runtime files (`.mjs`, `.cjs`)                                                   |\n| `./types/*`          | Types only | Type-only re-exports using `export type *`                                              |\n| `./types/internal/*` | Blocked    | Set to `null` to prevent access to internal types                                       |\n\n**Shim file examples**:\n\n```typescript\n// dist/client.d.ts (triple-slash reference for ambient types)\n/// <reference types=\"@voidzero-dev/vite-plus-core/client\" />\n\n// dist/module-runner.js\nexport * from '@voidzero-dev/vite-plus-core/module-runner';\n\n// dist/types/importMeta.d.ts (type-only export)\nexport type * from '@voidzero-dev/vite-plus-core/types/importMeta.d.ts';\n```\n\n**Note on export ordering**: In `package.json`, the `./types/internal/*` export (set to `null`) must appear before `./types/*` for correct precedence. More specific patterns must precede wildcards.\n\n### Step 4: Test Package Export Sync (`syncTestPackageExports`)\n\nReads the test package's exports and creates shim files that re-export everything under `./test/*`:\n\n```typescript\n// For each test package export like \"./browser-playwright\"\n// Creates a shim file: dist/test/browser-playwright.js\nexport * from '@voidzero-dev/vite-plus-test/browser-playwright';\n```\n\n**Input**: `../test/package.json` exports\n**Output**: `dist/test/*.js`, `dist/test/*.d.ts`, updated `package.json` exports\n\n---\n\n## Output Structure\n\n```\npackages/cli/\n├── dist/\n│   ├── index.js              # Main entry (ESM)\n│   ├── index.cjs             # Main entry (CJS)\n│   ├── index.d.ts            # Type declarations\n│   ├── bin.js                # CLI entry point\n│   ├── client.d.ts           # ./client types (triple-slash ref)\n│   ├── module-runner.js      # ./module-runner shim\n│   ├── module-runner.d.ts\n│   ├── internal.js           # ./internal shim\n│   ├── internal.d.ts\n│   ├── client/               # Synced client runtime files\n│   │   ├── client.mjs        # ESM client shim\n│   │   ├── client.d.ts\n│   │   ├── env.mjs\n│   │   └── ...\n│   ├── types/                # Synced type definitions\n│   │   ├── importMeta.d.ts   # Type shims (export type *)\n│   │   ├── importGlob.d.ts\n│   │   ├── customEvent.d.ts\n│   │   └── ...\n│   └── test/                 # Synced test exports\n│       ├── index.js          # Re-exports @voidzero-dev/vite-plus-test\n│       ├── index.cjs\n│       ├── index.d.ts\n│       ├── browser-playwright.js\n│       ├── browser-playwright.d.ts\n│       ├── plugins/\n│       │   ├── runner.js\n│       │   ├── utils.js\n│       │   ├── spy.js\n│       │   └── ... (33+ plugin shims)\n│       └── ...\n├── binding/\n│   ├── index.js              # NAPI binding JS wrapper\n│   ├── index.d.ts            # NAPI type declarations\n│   └── *.node                # Platform-specific binaries\n└── bin/\n    └── vite                  # Shell entry point\n```\n\n---\n\n## NAPI Targets\n\nThe CLI builds native bindings for the following platform targets:\n\n| Target                      | Platform | Architecture | Output File                       |\n| --------------------------- | -------- | ------------ | --------------------------------- |\n| `aarch64-apple-darwin`      | macOS    | ARM64        | `vite-plus.darwin-arm64.node`     |\n| `x86_64-apple-darwin`       | macOS    | x64          | `vite-plus.darwin-x64.node`       |\n| `aarch64-unknown-linux-gnu` | Linux    | ARM64        | `vite-plus.linux-arm64-gnu.node`  |\n| `x86_64-unknown-linux-gnu`  | Linux    | x64          | `vite-plus.linux-x64-gnu.node`    |\n| `aarch64-pc-windows-msvc`   | Windows  | ARM64        | `vite-plus.win32-arm64-msvc.node` |\n| `x86_64-pc-windows-msvc`    | Windows  | x64          | `vite-plus.win32-x64-msvc.node`   |\n\nThese targets are defined in `package.json` under the `napi.targets` field.\n\n---\n\n## Rolldown Native Binding Integration\n\nThe CLI package integrates with Rolldown at the native binding level, allowing vite-plus to ship as a self-contained package without requiring users to install separate `@rolldown/binding-*` packages.\n\n### Conditional Compilation\n\nRolldown bindings are **optionally** compiled into the vite-plus native module via Cargo feature flags.\n\n**In `binding/Cargo.toml`**:\n\n```toml\n[dependencies]\nrolldown_binding = { workspace = true, optional = true }\n\n[features]\nrolldown = [\"dep:rolldown_binding\"]\n```\n\n**In `binding/src/lib.rs`**:\n\n```rust\n#[cfg(feature = \"rolldown\")]\npub extern crate rolldown_binding;\n```\n\n### Build-Time Feature Activation\n\nThe rolldown feature is only enabled during release builds:\n\n```typescript\n// In build.ts\nawait cli.build({\n  features: process.env.RELEASE_BUILD ? ['rolldown'] : void 0,\n  release: process.env.VITE_PLUS_CLI_DEBUG !== '1',\n});\n```\n\n**When `RELEASE_BUILD=1`**:\n\n1. Enables the `rolldown` Cargo feature\n2. Compiles `rolldown_binding` into the `.node` file\n3. Extracts `napi.dtsHeader` from rolldown's package.json for type definitions\n4. Prepends custom type definitions to the generated `.d.ts` file\n\n### Why Conditional Compilation?\n\n| Build Type                  | rolldown Feature | Use Case                                |\n| --------------------------- | ---------------- | --------------------------------------- |\n| Development (`pnpm build`)  | Disabled         | Faster builds, smaller binaries         |\n| Release (`RELEASE_BUILD=1`) | Enabled          | Full distribution with bundled rolldown |\n\n### Module Specifier Rewriting\n\nDuring release builds, the core package rewrites all `@rolldown/binding-*` imports to point to `vite-plus/binding`:\n\n```typescript\n// In packages/core/build.ts\nif (process.env.RELEASE_BUILD) {\n  // @rolldown/binding-darwin-arm64 → vite-plus/binding\n  source = source.replace(/@rolldown\\/binding-([a-z0-9-]+)/g, 'vite-plus/binding');\n}\n```\n\n**Transformation examples**:\n\n| Original Import                    | After Rewrite       |\n| ---------------------------------- | ------------------- |\n| `@rolldown/binding-darwin-arm64`   | `vite-plus/binding` |\n| `@rolldown/binding-linux-x64-gnu`  | `vite-plus/binding` |\n| `@rolldown/binding-win32-x64-msvc` | `vite-plus/binding` |\n\nThis means:\n\n1. The bundled rolldown code in `@voidzero-dev/vite-plus-core/rolldown` resolves native bindings from `vite-plus/binding`\n2. Users don't need to install separate `@rolldown/binding-*` platform packages\n3. The single `.node` file contains both vite-plus task runner and rolldown bindings\n\n### Native Binding Contents\n\nWhen compiled with `RELEASE_BUILD=1`, the `.node` file contains:\n\n| Component          | Source                             | Purpose                        |\n| ------------------ | ---------------------------------- | ------------------------------ |\n| `vite_task`        | `packages/cli/binding/src/lib.rs`  | Task runner session management |\n| `rolldown_binding` | `rolldown/crates/rolldown_binding` | Rolldown bundler NAPI bindings |\n\n### Export Chain\n\n```\nUser imports 'vite-plus/rolldown'\n  → packages/cli re-exports from @voidzero-dev/vite-plus-core/rolldown\n    → packages/core/dist/rolldown/index.mjs\n      → Native binding: vite-plus/binding (rewritten from @rolldown/binding-*)\n        → binding/vite-plus.darwin-arm64.node (contains rolldown_binding)\n```\n\n### Platform-Specific Publishing\n\nNative bindings are published as separate platform packages for optimal install size:\n\n| Platform    | Published Package                         |\n| ----------- | ----------------------------------------- |\n| macOS ARM64 | `@voidzero-dev/vite-plus-darwin-arm64`    |\n| macOS x64   | `@voidzero-dev/vite-plus-darwin-x64`      |\n| Linux ARM64 | `@voidzero-dev/vite-plus-linux-arm64-gnu` |\n| Linux x64   | `@voidzero-dev/vite-plus-linux-x64-gnu`   |\n| Windows x64 | `@voidzero-dev/vite-plus-win32-x64-msvc`  |\n\nThese are automatically installed via `optionalDependencies` based on the user's platform.\n\nSee `publish-native-addons.ts` for the publishing pipeline.\n\n---\n\n## Core Package Export Sync Details\n\n### Why Shim Files?\n\nThe CLI package creates thin shim files that re-export from `@voidzero-dev/vite-plus-core` rather than bundling the actual code. This approach:\n\n1. **Enables drop-in replacement** - Users can replace `vite` with `vite-plus` without changing imports\n2. **Keeps packages in sync** - No need to rebuild CLI when core package changes\n3. **Reduces duplication** - No file copying, just re-exports\n4. **Preserves module resolution** - Node.js resolves to the actual core package\n\n**Note**: The `@voidzero-dev/vite-plus-core` package itself bundles multiple upstream projects (vite, rolldown, tsdown, vitepress). See [Core Package Bundling](../core/BUNDLING.md) for details.\n\n### Export Mapping (Core)\n\n| Upstream Vite Export | CLI Package Export        | Description                                |\n| -------------------- | ------------------------- | ------------------------------------------ |\n| `vite/client`        | `vite-plus/client`        | Ambient types for HMR, CSS modules, assets |\n| `vite/module-runner` | `vite-plus/module-runner` | SSR/Environment module runner              |\n| `vite/internal`      | `vite-plus/internal`      | Internal APIs                              |\n| `vite/dist/client/*` | `vite-plus/dist/client/*` | Client runtime files                       |\n| `vite/types/*`       | `vite-plus/types/*`       | Type definitions                           |\n\n### Type-Only Exports\n\nFor `./types/*` exports, shim files use `export type *` syntax (TypeScript 5.0+) to ensure only type information is re-exported:\n\n```typescript\n// dist/types/importMeta.d.ts\nexport type * from '@voidzero-dev/vite-plus-core/types/importMeta.d.ts';\n```\n\nThis is important because `./types/*` only exposes `.d.ts` files and should never include runtime code.\n\n### Internal Types Blocking\n\nThe `./types/internal/*` export is set to `null` in package.json to block access to internal type definitions:\n\n```json\n\"./types/internal/*\": null,\n\"./types/*\": { \"types\": \"./dist/types/*\" }\n```\n\nThe `syncTypesDir()` helper skips the top-level `internal` directory when creating shims, since access is blocked at the exports level.\n\n### Client Types (Triple-Slash Reference)\n\nThe `./client` export uses a triple-slash reference instead of a regular export because Vite's `client.d.ts` contains ambient type declarations (for CSS modules, assets, etc.) that should be globally available:\n\n```typescript\n// dist/client.d.ts\n/// <reference types=\"@voidzero-dev/vite-plus-core/client\" />\n```\n\nThis allows TypeScript to pick up types like `import.meta.hot`, CSS module types, and asset imports without explicit imports.\n\n---\n\n## Test Package Export Sync Details\n\n### Why Shim Files?\n\nInstead of copying the actual dist files from the test package, we create thin shim files that re-export from `@voidzero-dev/vite-plus-test`. This approach:\n\n1. **Keeps packages in sync** - No need to rebuild CLI when test package changes\n2. **Reduces duplication** - No file copying, just re-exports\n3. **Preserves module resolution** - Node.js resolves to the actual test package\n\n### Export Mapping (Test)\n\nAll test package exports are mapped under `./test/*`:\n\n| Test Package Export                               | CLI Package Export                  |\n| ------------------------------------------------- | ----------------------------------- |\n| `@voidzero-dev/vite-plus-test`                    | `vite-plus/test`                    |\n| `@voidzero-dev/vite-plus-test/browser`            | `vite-plus/test/browser`            |\n| `@voidzero-dev/vite-plus-test/browser-playwright` | `vite-plus/test/browser-playwright` |\n| `@voidzero-dev/vite-plus-test/plugins/runner`     | `vite-plus/test/plugins/runner`     |\n\n### Conditional Export Handling\n\nThe sync handles complex conditional exports with `import`/`require`/`node`/`types` conditions.\n\n**Test package's main export** (`\".\"`):\n\n```json\n\".\": {\n  \"import\": { \"types\": \"...\", \"node\": \"...\", \"default\": \"...\" },\n  \"require\": { \"types\": \"...\", \"default\": \"...\" }\n}\n```\n\n**Becomes CLI package export** (`\"./test\"`):\n\n```json\n\"./test\": {\n  \"import\": {\n    \"types\": \"./dist/test/index.d.ts\",\n    \"node\": \"./dist/test/index.js\",\n    \"default\": \"./dist/test/index.js\"\n  },\n  \"require\": {\n    \"types\": \"./dist/test/index.d.cts\",\n    \"default\": \"./dist/test/index.cjs\"\n  }\n}\n```\n\nFor each condition, appropriate shim files are created:\n\n- `.js` for ESM imports\n- `.cjs` for CommonJS requires\n- `.d.ts` / `.d.cts` for type declarations\n\n### Shim File Contents\n\n**ESM shim** (`dist/test/browser-playwright.js`):\n\n```javascript\nexport * from '@voidzero-dev/vite-plus-test/browser-playwright';\n```\n\n**CJS shim** (`dist/test/index.cjs`):\n\n```javascript\nmodule.exports = require('@voidzero-dev/vite-plus-test');\n```\n\n**Type shim** (`dist/test/browser-playwright.d.ts`):\n\n```typescript\nimport '@voidzero-dev/vite-plus-test/browser-playwright';\nexport * from '@voidzero-dev/vite-plus-test/browser-playwright';\n```\n\nNote: Type shims include a side-effect import to preserve module augmentations (e.g., `toMatchSnapshot` on the `Assertion` interface).\n\n---\n\n## Build Dependencies\n\n| Package        | Purpose                          |\n| -------------- | -------------------------------- |\n| `@napi-rs/cli` | NAPI build toolchain for Rust    |\n| `oxfmt`        | Code formatting for generated JS |\n| `typescript`   | TypeScript compilation           |\n\n---\n\n## Debug Mode\n\nTo build with debug (unoptimized) Rust bindings:\n\n```bash\nVITE_PLUS_CLI_DEBUG=1 pnpm build\n```\n\nThis sets `release: false` in the NAPI build options, producing larger but faster-to-compile debug binaries.\n\n---\n\n## Build Commands\n\n```bash\n# Build the CLI package (requires core package to be built first)\npnpm -C packages/cli build\n\n# Build from monorepo root (builds all dependencies first)\npnpm build --filter vite-plus\n\n# Debug build\nVITE_PLUS_CLI_DEBUG=1 pnpm -C packages/cli build\n```\n\n---\n\n## Package Exports\n\nAfter building, the CLI package exports:\n\n| Export Path                 | Description                         |\n| --------------------------- | ----------------------------------- |\n| `.`                         | Main entry (CLI utilities)          |\n| `./client`                  | Client types (ambient declarations) |\n| `./module-runner`           | Vite module runner for SSR          |\n| `./internal`                | Internal Vite APIs                  |\n| `./dist/client/*`           | Client runtime files                |\n| `./types/*`                 | Type definitions                    |\n| `./bin`                     | CLI binary entry point              |\n| `./binding`                 | NAPI native binding                 |\n| `./test`                    | Test package main entry             |\n| `./test/browser`            | Browser testing utilities           |\n| `./test/browser-playwright` | Playwright integration              |\n| `./test/plugins/*`          | Plugin shims for pnpm overrides     |\n| `./package.json`            | Package metadata                    |\n\nSee `package.json` for the complete list of exports.\n\n---\n\n## Technical Reference\n\n### Build Flow\n\n```\n1. buildCli()                TypeScript compilation -> dist/*.js\n2. buildNapiBinding()        Rust -> binding/*.node (per platform)\n3. syncCorePackageExports()  Read core pkg dist -> dist/client/, dist/types/\n   ├── createClientShim()        Triple-slash reference for ./client\n   ├── createModuleRunnerShim()  JS + types for ./module-runner\n   ├── createInternalShim()      JS + types for ./internal\n   ├── syncClientDir()           Shims for ./dist/client/*\n   └── syncTypesDir()            Type-only shims for ./types/*\n4. syncTestPackageExports()  Read test pkg exports -> dist/test/*\n   ├── createShimForExport()     Generate shim files\n   ├── createConditionalShim()   Handle import/require conditions\n   └── updateCliPackageJson()    Update exports in package.json\n```\n\n### Key Constants\n\n```typescript\n// Core package name for Vite compatibility exports\nconst CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';\n\n// Test package name for re-exports\nconst TEST_PACKAGE_NAME = '@voidzero-dev/vite-plus-test';\n```\n\n### Package.json Exports Management\n\nThe `exports` field in `package.json` has two categories: **manual** and **automated**.\n\n#### Manual exports\n\nAll non-`./test*` exports are manually maintained in `package.json`. These fall into two groups:\n\n**CLI-native exports** — point to CLI's own compiled TypeScript (built by `buildCli()` via tsc):\n\n| Export           | Description                |\n| ---------------- | -------------------------- |\n| `.`              | Main entry (CLI utilities) |\n| `./bin`          | CLI binary entry point     |\n| `./binding`      | NAPI native binding        |\n| `./lint`         | Lint utilities             |\n| `./pack`         | Pack utilities             |\n| `./package.json` | Package metadata           |\n\n**Core shim exports** — point to shim files auto-generated by `syncCorePackageExports()` that re-export from `@voidzero-dev/vite-plus-core`. The shim files are regenerated on each build, but the `package.json` entries themselves are manual:\n\n| Export               | Description                                                             |\n| -------------------- | ----------------------------------------------------------------------- |\n| `./client`           | Triple-slash reference for ambient type declarations (CSS modules, etc) |\n| `./module-runner`    | Vite module runner for SSR/environments                                 |\n| `./internal`         | Internal Vite APIs                                                      |\n| `./dist/client/*`    | Client runtime files                                                    |\n| `./types/internal/*` | Blocked (`null`) to prevent access to internal types                    |\n| `./types/*`          | Type-only re-exports                                                    |\n\n**Note**: The core package's own exports (which the shims point to) are generated upstream by `packages/tools/src/sync-remote-deps.ts`. See [Core Package Bundling](../core/BUNDLING.md) for details.\n\n#### Automated exports (`./test/*`)\n\nAll `./test*` exports are fully managed by `syncTestPackageExports()`. The build script:\n\n1. Reads `packages/test/package.json` exports\n2. Creates shim files in `dist/test/`\n3. Removes old `./test*` exports from `package.json`\n4. Merges in newly generated test exports\n5. Ensures `dist/test` is in the `files` array\n"
  },
  {
    "path": "packages/cli/README.md",
    "content": "# VITE+(⚡︎) Local CLI\n\n**The Unified Toolchain for the Web**\n_runtime and package management, create, dev, check, test, build, pack, and monorepo task caching in a single dependency_\n\nThis package provides the project-local version of Vite+. The global `vite` command automatically delegates to this package for all project-specific tasks.\n\n---\n\nVite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows:\n\n- **`vp env`:** Manage Node.js globally and per project\n- **`vp install`:** Install dependencies with automatic package manager detection\n- **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR\n- **`vp check`:** Run formatting, linting, and type checks in one command\n- **`vp test`:** Run tests through bundled Vitest\n- **`vp build`:** Build applications for production with Vite + Rolldown\n- **`vp run`:** Execute monorepo tasks with caching and dependency-aware scheduling\n- **`vp pack`:** Build libraries for npm publishing or standalone app binaries\n- **`vp create` / `vp migrate`:** Scaffold new projects and migrate existing ones\n\nAll of this is configured from your project root and works across Vite's framework ecosystem.\nVite+ is fully open-source under the MIT license.\n\n## Getting Started\n\nInstall Vite+ globally as `vp`:\n\nFor Linux or macOS:\n\n```bash\ncurl -fsSL https://vite.plus | bash\n```\n\nFor Windows:\n\n```bash\nirm https://viteplus.dev/install.ps1 | iex\n```\n\n`vp` handles the full development lifecycle such as package management, development servers, linting, formatting, testing and building for production.\n\n## Configuring Vite+\n\nVite+ can be configured using a single `vite.config.ts` at the root of your project:\n\n```ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  // Standard Vite configuration for dev/build/preview.\n  plugins: [],\n\n  // Vitest configuration.\n  test: {\n    include: ['src/**/*.test.ts'],\n  },\n\n  // Oxlint configuration.\n  lint: {\n    ignorePatterns: ['dist/**'],\n  },\n\n  // Oxfmt configuration.\n  fmt: {\n    semi: true,\n    singleQuote: true,\n  },\n\n  // Vite Task configuration.\n  run: {\n    tasks: {\n      'generate:icons': {\n        command: 'node scripts/generate-icons.js',\n        envs: ['ICON_THEME'],\n      },\n    },\n  },\n\n  // `vp staged` configuration.\n  staged: {\n    '*': 'vp check --fix',\n  },\n});\n```\n\nThis lets you keep the configuration for your development server, build, test, lint, format, task runner, and staged-file workflow in one place with type-safe config and shared defaults.\n\nUse `vp migrate` to migrate to Vite+. It merges tool-specific config files such as `.oxlintrc*`, `.oxfmtrc*`, and lint-staged config into `vite.config.ts`.\n\n### CLI Workflows (`vp help`)\n\n#### Start\n\n- **create** - Create a new project from a template\n- **migrate** - Migrate an existing project to Vite+\n- **config** - Configure hooks and agent integration\n- **staged** - Run linters on staged files\n- **install** (`i`) - Install dependencies\n- **env** - Manage Node.js versions\n\n#### Develop\n\n- **dev** - Run the development server\n- **check** - Run format, lint, and type checks\n- **lint** - Lint code\n- **fmt** - Format code\n- **test** - Run tests\n\n#### Execute\n\n- **run** - Run monorepo tasks\n- **exec** - Execute a command from local `node_modules/.bin`\n- **dlx** - Execute a package binary without installing it as a dependency\n- **cache** - Manage the task cache\n\n#### Build\n\n- **build** - Build for production\n- **pack** - Build libraries\n- **preview** - Preview production build\n\n#### Manage Dependencies\n\nVite+ automatically wraps your package manager (pnpm, npm, or Yarn) based on `packageManager` and lockfiles:\n\n- **add** - Add packages to dependencies\n- **remove** (`rm`, `un`, `uninstall`) - Remove packages from dependencies\n- **update** (`up`) - Update packages to latest versions\n- **dedupe** - Deduplicate dependencies\n- **outdated** - Check outdated packages\n- **list** (`ls`) - List installed packages\n- **why** (`explain`) - Show why a package is installed\n- **info** (`view`, `show`) - View package metadata from the registry\n- **link** (`ln`) / **unlink** - Manage local package links\n- **pm** - Forward a command to the package manager\n\n#### Maintain\n\n- **upgrade** - Update `vp` itself to the latest version\n- **implode** - Remove `vp` and all related data\n\n### Scaffolding your first Vite+ project\n\nUse `vp create` to create a new project:\n\n```bash\nvp create\n```\n\nYou can run `vp create` inside of a project to add new apps or libraries to your project.\n\n### Migrating an existing project\n\nYou can migrate an existing project to Vite+:\n\n```bash\nvp migrate\n```\n\n### GitHub Actions\n\nUse the official [`setup-vp`](https://github.com/voidzero-dev/setup-vp) action to install Vite+ in GitHub Actions:\n\n```yaml\n- uses: voidzero-dev/setup-vp@v1\n  with:\n    node-version: '22'\n    cache: true\n```\n\n#### Manual Installation & Migration\n\nIf you are manually migrating a project to Vite+, install these dev dependencies first:\n\n```bash\nnpm install -D vite-plus @voidzero-dev/vite-plus-core@latest\n```\n\nYou need to add overrides to your package manager for `vite` and `vitest` so that other packages depending on Vite and Vitest will use the Vite+ versions:\n\n```json\n\"overrides\": {\n  \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n  \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n}\n```\n\nIf you are using `pnpm`, add this to your `pnpm-workspace.yaml`:\n\n```yaml\noverrides:\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n```\n\nOr, if you are using Yarn:\n\n```json\n\"resolutions\": {\n  \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n  \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n}\n```\n\n## Sponsors\n\nThanks to [namespace.so](https://namespace.so) for powering our CI/CD pipelines with fast, free macOS and Linux runners.\n"
  },
  {
    "path": "packages/cli/bin/oxfmt",
    "content": "#!/usr/bin/env node\n\n// LSP-only wrapper for oxfmt.\n// This enables IDE extensions (e.g., oxc-vscode) to discover and start the LSP server.\n// Binary resolution follows the same approach as `src/resolve-fmt.ts`.\n\nif (!process.argv.includes('--lsp')) {\n  console.error('This oxfmt wrapper is for IDE extension use only (--lsp mode).');\n  console.error('To format your code, run: vp fmt');\n  process.exit(1);\n}\n\nimport { createRequire } from 'node:module';\nimport { pathToFileURL } from 'node:url';\n\nconst require = createRequire(import.meta.url);\nconst oxfmtBin = require.resolve('oxfmt/bin/oxfmt');\n\nawait import(pathToFileURL(oxfmtBin).href);\n"
  },
  {
    "path": "packages/cli/bin/oxlint",
    "content": "#!/usr/bin/env node\n\n// LSP-only wrapper for oxlint.\n// This enables IDE extensions (e.g., oxc-vscode) to discover and start the LSP server.\n// Binary resolution follows the same approach as `src/resolve-lint.ts`.\n\nif (!process.argv.includes('--lsp')) {\n  console.error('This oxlint wrapper is for IDE extension use only (--lsp mode).');\n  console.error('To lint your code, run: vp lint');\n  process.exit(1);\n}\n\nimport { createRequire } from 'node:module';\nimport { dirname, join } from 'node:path';\nimport { pathToFileURL } from 'node:url';\n\nconst require = createRequire(import.meta.url);\nconst oxlintMainPath = require.resolve('oxlint');\nconst oxlintBin = join(dirname(dirname(oxlintMainPath)), 'bin', 'oxlint');\n\nawait import(pathToFileURL(oxlintBin).href);\n"
  },
  {
    "path": "packages/cli/bin/vp",
    "content": "#!/usr/bin/env node\n\nimport module from 'node:module';\nif (module.enableCompileCache) {\n  module.enableCompileCache();\n}\nimport '../dist/bin.js';\n"
  },
  {
    "path": "packages/cli/binding/.gitignore",
    "content": "*.node\n*.wasm"
  },
  {
    "path": "packages/cli/binding/Cargo.toml",
    "content": "[package]\nname = \"vite-plus-cli\"\nversion = \"0.0.0\"\nedition.workspace = true\n\n[features]\nrolldown = [\"dep:rolldown_binding\"]\n\n[dependencies]\nanyhow = { workspace = true }\nasync-trait = { workspace = true }\nclap = { workspace = true, features = [\"derive\"] }\nfspy = { workspace = true }\nrustc-hash = { workspace = true }\nnapi = { workspace = true }\nnapi-derive = { workspace = true }\npetgraph = { workspace = true }\nowo-colors = { workspace = true }\nserde = { workspace = true, features = [\"derive\"] }\nserde_json = { workspace = true }\ntokio = { workspace = true, features = [\"fs\"] }\ntracing = { workspace = true }\nvite_command = { workspace = true }\nvite_error = { workspace = true }\nvite_install = { workspace = true }\nvite_migration = { workspace = true }\nvite_path = { workspace = true }\nvite_shared = { workspace = true }\nvite_static_config = { workspace = true }\nvite_str = { workspace = true }\nvite_task = { workspace = true }\nvite_workspace = { workspace = true }\nrolldown_binding = { workspace = true, optional = true }\n\n[build-dependencies]\nnapi-build = { workspace = true }\n\n[dev-dependencies]\npretty_assertions = { workspace = true }\n\n[lib]\ncrate-type = [\"cdylib\"]\n"
  },
  {
    "path": "packages/cli/binding/build.rs",
    "content": "fn main() {\n    napi_build::setup();\n}\n"
  },
  {
    "path": "packages/cli/binding/index.cjs",
    "content": "// prettier-ignore\n/* eslint-disable */\n// @ts-nocheck\n/* auto-generated by NAPI-RS */\n\nconst { readFileSync } = require('node:fs')\nlet nativeBinding = null;\nconst loadErrors = [];\n\nconst isMusl = () => {\n  let musl = false;\n  if (process.platform === 'linux') {\n    musl = isMuslFromFilesystem();\n    if (musl === null) {\n      musl = isMuslFromReport();\n    }\n    if (musl === null) {\n      musl = isMuslFromChildProcess();\n    }\n  }\n  return musl;\n};\n\nconst isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-');\n\nconst isMuslFromFilesystem = () => {\n  try {\n    return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl');\n  } catch {\n    return null;\n  }\n};\n\nconst isMuslFromReport = () => {\n  let report = null;\n  if (typeof process.report?.getReport === 'function') {\n    process.report.excludeNetwork = true;\n    report = process.report.getReport();\n  }\n  if (!report) {\n    return null;\n  }\n  if (report.header && report.header.glibcVersionRuntime) {\n    return false;\n  }\n  if (Array.isArray(report.sharedObjects)) {\n    if (report.sharedObjects.some(isFileMusl)) {\n      return true;\n    }\n  }\n  return false;\n};\n\nconst isMuslFromChildProcess = () => {\n  try {\n    return require('child_process')\n      .execSync('ldd --version', { encoding: 'utf8' })\n      .includes('musl');\n  } catch (e) {\n    // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false\n    return false;\n  }\n};\n\nfunction requireNative() {\n  if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) {\n    try {\n      return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH);\n    } catch (err) {\n      loadErrors.push(err);\n    }\n  } else if (process.platform === 'android') {\n    if (process.arch === 'arm64') {\n      try {\n        return require('./vite-plus.android-arm64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-android-arm64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-android-arm64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 'arm') {\n      try {\n        return require('./vite-plus.android-arm-eabi.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-android-arm-eabi');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-android-arm-eabi/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else {\n      loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`));\n    }\n  } else if (process.platform === 'win32') {\n    if (process.arch === 'x64') {\n      if (\n        process.config?.variables?.shlib_suffix === 'dll.a' ||\n        process.config?.variables?.node_target_type === 'shared_library'\n      ) {\n        try {\n          return require('./vite-plus.win32-x64-gnu.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-win32-x64-gnu');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-win32-x64-gnu/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      } else {\n        try {\n          return require('./vite-plus.win32-x64-msvc.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-win32-x64-msvc');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-win32-x64-msvc/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      }\n    } else if (process.arch === 'ia32') {\n      try {\n        return require('./vite-plus.win32-ia32-msvc.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-win32-ia32-msvc');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-win32-ia32-msvc/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 'arm64') {\n      try {\n        return require('./vite-plus.win32-arm64-msvc.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-win32-arm64-msvc');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-win32-arm64-msvc/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else {\n      loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`));\n    }\n  } else if (process.platform === 'darwin') {\n    try {\n      return require('./vite-plus.darwin-universal.node');\n    } catch (e) {\n      loadErrors.push(e);\n    }\n    try {\n      const binding = require('@voidzero-dev/vite-plus-darwin-universal');\n      const bindingPackageVersion =\n        require('@voidzero-dev/vite-plus-darwin-universal/package.json').version;\n      if (\n        bindingPackageVersion !== '0.0.0' &&\n        process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n        process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n      ) {\n        throw new Error(\n          `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n        );\n      }\n      return binding;\n    } catch (e) {\n      loadErrors.push(e);\n    }\n    if (process.arch === 'x64') {\n      try {\n        return require('./vite-plus.darwin-x64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-darwin-x64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-darwin-x64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 'arm64') {\n      try {\n        return require('./vite-plus.darwin-arm64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-darwin-arm64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-darwin-arm64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else {\n      loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`));\n    }\n  } else if (process.platform === 'freebsd') {\n    if (process.arch === 'x64') {\n      try {\n        return require('./vite-plus.freebsd-x64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-freebsd-x64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-freebsd-x64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 'arm64') {\n      try {\n        return require('./vite-plus.freebsd-arm64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-freebsd-arm64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-freebsd-arm64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else {\n      loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`));\n    }\n  } else if (process.platform === 'linux') {\n    if (process.arch === 'x64') {\n      if (isMusl()) {\n        try {\n          return require('./vite-plus.linux-x64-musl.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-x64-musl');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-x64-musl/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      } else {\n        try {\n          return require('./vite-plus.linux-x64-gnu.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-x64-gnu');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-x64-gnu/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      }\n    } else if (process.arch === 'arm64') {\n      if (isMusl()) {\n        try {\n          return require('./vite-plus.linux-arm64-musl.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-arm64-musl');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-arm64-musl/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      } else {\n        try {\n          return require('./vite-plus.linux-arm64-gnu.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-arm64-gnu');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-arm64-gnu/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      }\n    } else if (process.arch === 'arm') {\n      if (isMusl()) {\n        try {\n          return require('./vite-plus.linux-arm-musleabihf.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-arm-musleabihf');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-arm-musleabihf/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      } else {\n        try {\n          return require('./vite-plus.linux-arm-gnueabihf.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-arm-gnueabihf');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-arm-gnueabihf/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      }\n    } else if (process.arch === 'loong64') {\n      if (isMusl()) {\n        try {\n          return require('./vite-plus.linux-loong64-musl.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-loong64-musl');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-loong64-musl/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      } else {\n        try {\n          return require('./vite-plus.linux-loong64-gnu.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-loong64-gnu');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-loong64-gnu/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      }\n    } else if (process.arch === 'riscv64') {\n      if (isMusl()) {\n        try {\n          return require('./vite-plus.linux-riscv64-musl.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-riscv64-musl');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-riscv64-musl/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      } else {\n        try {\n          return require('./vite-plus.linux-riscv64-gnu.node');\n        } catch (e) {\n          loadErrors.push(e);\n        }\n        try {\n          const binding = require('@voidzero-dev/vite-plus-linux-riscv64-gnu');\n          const bindingPackageVersion =\n            require('@voidzero-dev/vite-plus-linux-riscv64-gnu/package.json').version;\n          if (\n            bindingPackageVersion !== '0.0.0' &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n            process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n          ) {\n            throw new Error(\n              `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n            );\n          }\n          return binding;\n        } catch (e) {\n          loadErrors.push(e);\n        }\n      }\n    } else if (process.arch === 'ppc64') {\n      try {\n        return require('./vite-plus.linux-ppc64-gnu.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-linux-ppc64-gnu');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-linux-ppc64-gnu/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 's390x') {\n      try {\n        return require('./vite-plus.linux-s390x-gnu.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-linux-s390x-gnu');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-linux-s390x-gnu/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else {\n      loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`));\n    }\n  } else if (process.platform === 'openharmony') {\n    if (process.arch === 'arm64') {\n      try {\n        return require('./vite-plus.openharmony-arm64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-openharmony-arm64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-openharmony-arm64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 'x64') {\n      try {\n        return require('./vite-plus.openharmony-x64.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-openharmony-x64');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-openharmony-x64/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else if (process.arch === 'arm') {\n      try {\n        return require('./vite-plus.openharmony-arm.node');\n      } catch (e) {\n        loadErrors.push(e);\n      }\n      try {\n        const binding = require('@voidzero-dev/vite-plus-openharmony-arm');\n        const bindingPackageVersion =\n          require('@voidzero-dev/vite-plus-openharmony-arm/package.json').version;\n        if (\n          bindingPackageVersion !== '0.0.0' &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK &&\n          process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0'\n        ) {\n          throw new Error(\n            `Native binding package version mismatch, expected 0.0.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`,\n          );\n        }\n        return binding;\n      } catch (e) {\n        loadErrors.push(e);\n      }\n    } else {\n      loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`));\n    }\n  } else {\n    loadErrors.push(\n      new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`),\n    );\n  }\n}\n\nnativeBinding = requireNative();\n\nif (!nativeBinding || process.env.NAPI_RS_FORCE_WASI) {\n  let wasiBinding = null;\n  let wasiBindingError = null;\n  try {\n    wasiBinding = require('./vite-plus.wasi.cjs');\n    nativeBinding = wasiBinding;\n  } catch (err) {\n    if (process.env.NAPI_RS_FORCE_WASI) {\n      wasiBindingError = err;\n    }\n  }\n  if (!nativeBinding) {\n    try {\n      wasiBinding = require('@voidzero-dev/vite-plus-wasm32-wasi');\n      nativeBinding = wasiBinding;\n    } catch (err) {\n      if (process.env.NAPI_RS_FORCE_WASI) {\n        wasiBindingError.cause = err;\n        loadErrors.push(err);\n      }\n    }\n  }\n  if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) {\n    const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error');\n    error.cause = wasiBindingError;\n    throw error;\n  }\n}\n\nif (!nativeBinding) {\n  if (loadErrors.length > 0) {\n    throw new Error(\n      `Cannot find native binding. ` +\n        `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` +\n        'Please try `npm i` again after removing both package-lock.json and node_modules directory.',\n      {\n        cause: loadErrors.reduce((err, cur) => {\n          cur.cause = err;\n          return cur;\n        }),\n      },\n    );\n  }\n  throw new Error(`Failed to load native binding`);\n}\n\nmodule.exports = nativeBinding;\nmodule.exports.detectWorkspace = nativeBinding.detectWorkspace;\nmodule.exports.downloadPackageManager = nativeBinding.downloadPackageManager;\nmodule.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig;\nmodule.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig;\nmodule.exports.rewriteEslint = nativeBinding.rewriteEslint;\nmodule.exports.rewriteImportsInDirectory = nativeBinding.rewriteImportsInDirectory;\nmodule.exports.rewritePrettier = nativeBinding.rewritePrettier;\nmodule.exports.rewriteScripts = nativeBinding.rewriteScripts;\nmodule.exports.run = nativeBinding.run;\nmodule.exports.runCommand = nativeBinding.runCommand;\nmodule.exports.vitePlusHeader = nativeBinding.vitePlusHeader;\n"
  },
  {
    "path": "packages/cli/binding/index.d.cts",
    "content": "/* auto-generated by NAPI-RS */\n/* eslint-disable */\n/** Error from batch import rewriting */\nexport interface BatchRewriteError {\n  /** The file path that had an error */\n  path: string;\n  /** The error message */\n  message: string;\n}\n\n/** Result of rewriting imports in multiple files */\nexport interface BatchRewriteResult {\n  /** Files that were modified */\n  modifiedFiles: Array<string>;\n  /** Files that had errors */\n  errors: Array<BatchRewriteError>;\n}\n\n/** Configuration options passed from JavaScript to Rust. */\nexport interface CliOptions {\n  lint: (err: Error | null) => Promise<JsCommandResolvedResult>;\n  fmt: (err: Error | null) => Promise<JsCommandResolvedResult>;\n  vite: (err: Error | null) => Promise<JsCommandResolvedResult>;\n  test: (err: Error | null) => Promise<JsCommandResolvedResult>;\n  pack: (err: Error | null) => Promise<JsCommandResolvedResult>;\n  doc: (err: Error | null) => Promise<JsCommandResolvedResult>;\n  cwd?: string;\n  /** CLI arguments (should be process.argv.slice(2) from JavaScript) */\n  args?: Array<string>;\n  /** Read the vite.config.ts in the Node.js side and return the `lint` and `fmt` config JSON string back to the Rust side */\n  resolveUniversalViteConfig: (err: Error | null, arg: string) => Promise<string>;\n}\n\n/**\n * Detect the workspace root and package manager type and version\n *\n * ## Parameters\n *\n * - `cwd`: The current working directory to detect the workspace root\n *\n * ## Returns\n *\n * Returns a `DetectWorkspaceResult` containing:\n * - The name of the package manager\n * - The version of the package manager\n * - Whether the workspace is a monorepo\n * - The workspace root, where the package.json file is located.\n *\n * ## Example\n *\n * ```javascript\n * const result = await detectWorkspace(\"/path/to/workspace\");\n * console.log(`Package manager name: ${result.packageManagerName}`);\n * console.log(`Package manager version: ${result.packageManagerVersion}`);\n * console.log(`Is monorepo: ${result.isMonorepo}`);\n * console.log(`Workspace root: ${result.root}`);\n * ```\n */\nexport declare function detectWorkspace(cwd: string): Promise<DetectWorkspaceResult>;\n\nexport interface DetectWorkspaceResult {\n  packageManagerName?: string;\n  packageManagerVersion?: string;\n  isMonorepo: boolean;\n  root?: string;\n}\n\n/**\n * Download a package manager\n *\n * ## Parameters\n *\n * - `options`: Configuration for the package manager to download, including:\n *   - `name`: The name of the package manager\n *   - `version`: The version of the package manager\n *   - `expected_hash`: The expected hash of the package manager\n *\n * ## Returns\n *\n * Returns a `DownloadPackageManagerResult` containing:\n * - The name of the package manager\n * - The install directory of the package manager\n * - The binary prefix of the package manager\n * - The package name of the package manager\n * - The version of the package manager\n *\n * ## Example\n *\n * ```javascript\n * const result = await downloadPackageManager({\n *   name: \"pnpm\",\n *   version: \"latest\",\n * });\n * console.log(`Package manager name: ${result.name}`);\n * console.log(`Package manager install directory: ${result.installDir}`);\n * console.log(`Package manager binary prefix: ${result.binPrefix}`);\n * console.log(`Package manager package name: ${result.packageName}`);\n * console.log(`Package manager version: ${result.version}`);\n * ```\n */\nexport declare function downloadPackageManager(\n  options: DownloadPackageManagerOptions,\n): Promise<DownloadPackageManagerResult>;\n\nexport interface DownloadPackageManagerOptions {\n  name: string;\n  version: string;\n  expectedHash?: string;\n}\n\nexport interface DownloadPackageManagerResult {\n  name: string;\n  installDir: string;\n  binPrefix: string;\n  packageName: string;\n  version: string;\n}\n\n/** Result returned by JavaScript resolver functions. */\nexport interface JsCommandResolvedResult {\n  binPath: string;\n  envs: Record<string, string>;\n}\n\n/**\n * Merge JSON configuration file into vite config file\n *\n * This function reads the files from disk and merges the JSON config\n * into the vite configuration file.\n *\n * # Arguments\n *\n * * `vite_config_path` - Path to the vite.config.ts or vite.config.js file\n * * `json_config_path` - Path to the JSON config file (e.g., .oxlintrc, .oxfmtrc)\n * * `config_key` - The key to use in the vite config (e.g., \"lint\", \"fmt\")\n *\n * # Returns\n *\n * Returns a `MergeJsonConfigResult` containing:\n * - `content`: The updated vite config content\n * - `updated`: Whether any changes were made\n * - `usesFunctionCallback`: Whether the config uses a function callback\n *\n * # Example\n *\n * ```javascript\n * const result = mergeJsonConfig('vite.config.ts', '.oxlintrc', 'lint');\n * if (result.updated) {\n *     fs.writeFileSync('vite.config.ts', result.content);\n * }\n * ```\n */\nexport declare function mergeJsonConfig(\n  viteConfigPath: string,\n  jsonConfigPath: string,\n  configKey: string,\n): MergeJsonConfigResult;\n\n/** Result of merging JSON config into vite config */\nexport interface MergeJsonConfigResult {\n  /** The updated vite config content */\n  content: string;\n  /** Whether any changes were made */\n  updated: boolean;\n  /** Whether the config uses a function callback */\n  usesFunctionCallback: boolean;\n}\n\n/**\n * Merge tsdown config into vite config by importing it\n *\n * This function adds an import statement for the tsdown config file\n * and adds `pack: packConfig` to the defineConfig.\n *\n * # Arguments\n *\n * * `vite_config_path` - Path to the vite.config.ts or vite.config.js file\n * * `tsdown_config_path` - Relative path to the tsdown.config.ts file (e.g., \"./tsdown.config.ts\")\n *\n * # Returns\n *\n * Returns a `MergeJsonConfigResult` containing:\n * - `content`: The updated vite config content\n * - `updated`: Whether any changes were made\n * - `usesFunctionCallback`: Whether the config uses a function callback\n *\n * # Example\n *\n * ```javascript\n * const result = mergeTsdownConfig('vite.config.ts', './tsdown.config.ts');\n * if (result.updated) {\n *     fs.writeFileSync('vite.config.ts', result.content);\n * }\n * ```\n */\nexport declare function mergeTsdownConfig(\n  viteConfigPath: string,\n  tsdownConfigPath: string,\n): MergeJsonConfigResult;\n\n/** Access modes for a path. */\nexport interface PathAccess {\n  /** Whether the path was read */\n  read: boolean;\n  /** Whether the path was written */\n  write: boolean;\n  /** Whether the path was read as a directory */\n  readDir: boolean;\n}\n\n/**\n * Rewrite ESLint scripts: rename `eslint` → `vp lint` and strip ESLint-only flags.\n *\n * Uses brush-parser to parse shell commands, so it correctly handles env var prefixes,\n * compound commands (`&&`, `||`, `|`), and quoted arguments.\n *\n * # Arguments\n *\n * * `scripts_json` - The scripts section as a JSON string\n *\n * # Returns\n *\n * * `updated` - The updated scripts JSON string, or `null` if no changes were made\n */\nexport declare function rewriteEslint(scriptsJson: string): string | null;\n\n/**\n * Rewrite imports in all TypeScript/JavaScript files under a directory\n *\n * This function finds all TypeScript and JavaScript files in the specified directory\n * (respecting `.gitignore` rules), applies the import rewrite rules to each file,\n * and writes the modified content back to disk.\n *\n * # Arguments\n *\n * * `root` - The root directory to search for files\n *\n * # Returns\n *\n * Returns a `BatchRewriteResult` containing:\n * - `modifiedFiles`: Files that were changed\n * - `errors`: Files that had errors during processing\n *\n * # Example\n *\n * ```javascript\n * const result = rewriteImportsInDirectory('./src');\n * console.log(`Modified ${result.modifiedFiles.length} files`);\n * for (const file of result.modifiedFiles) {\n *     console.log(`  ${file}`);\n * }\n * ```\n */\nexport declare function rewriteImportsInDirectory(root: string): BatchRewriteResult;\n\n/**\n * Rewrite Prettier scripts: rename `prettier` → `vp fmt` and strip Prettier-only flags.\n *\n * Uses brush-parser to parse shell commands, so it correctly handles env var prefixes,\n * compound commands (`&&`, `||`, `|`), and quoted arguments.\n *\n * # Arguments\n *\n * * `scripts_json` - The scripts section as a JSON string\n *\n * # Returns\n *\n * * `updated` - The updated scripts JSON string, or `null` if no changes were made\n */\nexport declare function rewritePrettier(scriptsJson: string): string | null;\n\n/**\n * Rewrite scripts json content using rules from rules_yaml\n *\n * # Arguments\n *\n * * `scripts_json` - The scripts section of the package.json file as a JSON string\n * * `rules_yaml` - The ast-grep rules.yaml as a YAML string\n *\n * # Returns\n *\n * * `updated` - The updated scripts section of the package.json file as a JSON string, or `null` if no updates were made\n *\n * # Example\n *\n * ```javascript\n * const updated = rewriteScripts(\"scripts section json content here\", \"ast-grep rules yaml content here\");\n * console.log(`Updated: ${updated}`);\n * ```\n */\nexport declare function rewriteScripts(scriptsJson: string, rulesYaml: string): string | null;\n\n/**\n * Main entry point for the CLI, called from JavaScript.\n *\n * This is an async function that spawns a new thread for the non-Send async code\n * from vite_task, while allowing the NAPI async context to continue running\n * and process JavaScript callbacks (via ThreadsafeFunction).\n */\nexport declare function run(options: CliOptions): Promise<number>;\n\n/**\n * Run a command with fspy tracking, callable from JavaScript.\n *\n * This function wraps `vite_command::run_command_with_fspy` to provide\n * a JavaScript-friendly interface for executing commands and tracking\n * their file system accesses.\n *\n * ## Parameters\n *\n * - `options`: Configuration for the command to run, including:\n *   - `bin_name`: The name of the binary to execute\n *   - `args`: Command line arguments\n *   - `envs`: Environment variables\n *   - `cwd`: Working directory\n *\n * ## Returns\n *\n * Returns a `RunCommandResult` containing:\n * - The exit code of the command\n * - A map of file paths accessed and their access modes\n *\n * ## Example\n *\n * ```javascript\n * const result = await runCommand({\n *   binName: \"node\",\n *   args: [\"-p\", \"console.log('hello')\"],\n *   envs: { PATH: process.env.PATH },\n *   cwd: \"/tmp\"\n * });\n * console.log(`Exit code: ${result.exitCode}`);\n * console.log(`Path accesses:`, result.pathAccesses);\n * ```\n */\nexport declare function runCommand(options: RunCommandOptions): Promise<RunCommandResult>;\n\n/**\n * Input parameters for running a command with fspy tracking.\n *\n * This structure contains the information needed to execute a command:\n * - `bin_name`: The name of the binary to execute\n * - `args`: Command line arguments to pass to the binary\n * - `envs`: Environment variables to set when executing the command\n * - `cwd`: The current working directory for the command\n */\nexport interface RunCommandOptions {\n  /** The name of the binary to execute */\n  binName: string;\n  /** Command line arguments to pass to the binary */\n  args: Array<string>;\n  /** Environment variables to set when executing the command */\n  envs: Record<string, string>;\n  /** The current working directory for the command */\n  cwd: string;\n}\n\n/**\n * Result returned by the run_command function.\n *\n * This structure contains:\n * - `exit_code`: The exit code of the command\n * - `path_accesses`: A map of relative paths to their access modes\n */\nexport interface RunCommandResult {\n  /** The exit code of the command */\n  exitCode: number;\n  /** Map of relative paths to their access modes */\n  pathAccesses: Record<string, PathAccess>;\n}\n\n/** Render the Vite+ header using the Rust implementation. */\nexport declare function vitePlusHeader(): string;\n"
  },
  {
    "path": "packages/cli/binding/index.d.ts",
    "content": "export * from './index.cjs';\n"
  },
  {
    "path": "packages/cli/binding/index.js",
    "content": "export * from './index.cjs';\n"
  },
  {
    "path": "packages/cli/binding/src/cli.rs",
    "content": "//! CLI types and logic for vite-plus using the new Session API from vite-task.\n//!\n//! This module contains all the CLI-related code.\n//! It handles argument parsing, command dispatching, and orchestration of the task execution.\n\nuse std::{\n    borrow::Cow, env, ffi::OsStr, future::Future, io::IsTerminal, iter, pin::Pin, process::Stdio,\n    sync::Arc, time::Instant,\n};\n\nuse clap::{\n    Parser, Subcommand,\n    error::{ContextKind, ContextValue, ErrorKind},\n};\nuse owo_colors::OwoColorize;\nuse rustc_hash::FxHashMap;\nuse serde::{Deserialize, Serialize};\nuse vite_error::Error;\nuse vite_path::{AbsolutePath, AbsolutePathBuf};\nuse vite_shared::{PrependOptions, output, prepend_to_path_env};\nuse vite_str::Str;\nuse vite_task::{\n    Command, CommandHandler, ExitStatus, HandledCommand, ScriptCommand, Session, SessionConfig,\n    config::{\n        UserRunConfig,\n        user::{EnabledCacheConfig, UserCacheConfig},\n    },\n    loader::UserConfigLoader,\n    plan_request::SyntheticPlanRequest,\n};\n\n/// Resolved configuration from vite.config.ts\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ResolvedUniversalViteConfig {\n    #[serde(rename = \"configFile\")]\n    pub config_file: Option<String>,\n    pub lint: Option<serde_json::Value>,\n    pub fmt: Option<serde_json::Value>,\n    pub run: Option<serde_json::Value>,\n}\n\n/// Result type for resolved commands from JavaScript\n#[derive(Debug, Clone)]\npub struct ResolveCommandResult {\n    pub bin_path: Arc<OsStr>,\n    pub envs: Vec<(String, String)>,\n}\n\n/// Built-in subcommands that resolve to a concrete tool (oxlint, vitest, vite, etc.)\n#[derive(Debug, Clone, Subcommand)]\npub enum SynthesizableSubcommand {\n    /// Lint code\n    #[command(disable_help_flag = true)]\n    Lint {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Format code\n    #[command(disable_help_flag = true)]\n    Fmt {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Build for production\n    #[command(disable_help_flag = true)]\n    Build {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Run tests\n    #[command(disable_help_flag = true)]\n    Test {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Build library\n    #[command(disable_help_flag = true)]\n    Pack {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Run the development server\n    #[command(disable_help_flag = true)]\n    Dev {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Preview production build\n    #[command(disable_help_flag = true)]\n    Preview {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Build documentation\n    #[command(disable_help_flag = true, hide = true)]\n    Doc {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Install command.\n    #[command(disable_help_flag = true, alias = \"i\")]\n    Install {\n        #[clap(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n    /// Run format, lint, and type checks\n    Check {\n        /// Auto-fix format and lint issues\n        #[arg(long)]\n        fix: bool,\n        /// Skip format check\n        #[arg(long = \"no-fmt\")]\n        no_fmt: bool,\n        /// Skip lint check\n        #[arg(long = \"no-lint\")]\n        no_lint: bool,\n        /// File paths to check (passed through to fmt and lint)\n        #[arg(trailing_var_arg = true)]\n        paths: Vec<String>,\n    },\n}\n\n/// Top-level CLI argument parser for vite-plus.\n#[derive(Debug, Parser)]\n#[command(name = \"vp\", disable_help_subcommand = true)]\nenum CLIArgs {\n    /// vite-task commands (run, cache)\n    #[command(flatten)]\n    ViteTask(Command),\n\n    /// Built-in subcommands (lint, build, test, etc.)\n    #[command(flatten)]\n    Synthesizable(SynthesizableSubcommand),\n\n    /// Execute a command from local node_modules/.bin\n    Exec(crate::exec::ExecArgs),\n}\n\n/// Type alias for boxed async resolver function\n/// NOTE: Uses anyhow::Error to avoid NAPI type inference issues\npub type BoxedResolverFn =\n    Box<dyn Fn() -> Pin<Box<dyn Future<Output = anyhow::Result<ResolveCommandResult>> + 'static>>>;\n\n/// Type alias for vite config resolver function (takes package path, returns JSON string)\n/// Uses Arc for cloning and Send + Sync for use in UserConfigLoader\npub type ViteConfigResolverFn = Arc<\n    dyn Fn(String) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'static>>\n        + Send\n        + Sync,\n>;\n\n/// CLI options containing JavaScript resolver functions (using boxed futures for simplicity)\npub struct CliOptions {\n    pub lint: BoxedResolverFn,\n    pub fmt: BoxedResolverFn,\n    pub vite: BoxedResolverFn,\n    pub test: BoxedResolverFn,\n    pub pack: BoxedResolverFn,\n    pub doc: BoxedResolverFn,\n    pub resolve_universal_vite_config: ViteConfigResolverFn,\n}\n\n/// A resolved subcommand ready for execution.\nstruct ResolvedSubcommand {\n    program: Arc<OsStr>,\n    args: Arc<[Str]>,\n    cache_config: UserCacheConfig,\n    envs: Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n}\n\nimpl ResolvedSubcommand {\n    fn into_synthetic_plan_request(self) -> SyntheticPlanRequest {\n        SyntheticPlanRequest {\n            program: self.program,\n            args: self.args,\n            cache_config: self.cache_config,\n            envs: self.envs,\n        }\n    }\n}\n\n/// Resolves synthesizable subcommands to concrete programs and arguments.\n/// Used by both direct CLI execution and CommandHandler.\npub struct SubcommandResolver {\n    cli_options: Option<CliOptions>,\n    workspace_path: Arc<AbsolutePath>,\n}\n\nimpl std::fmt::Debug for SubcommandResolver {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"SubcommandResolver\")\n            .field(\"has_cli_options\", &self.cli_options.is_some())\n            .field(\"workspace_path\", &self.workspace_path)\n            .finish()\n    }\n}\n\nimpl SubcommandResolver {\n    pub fn new(workspace_path: Arc<AbsolutePath>) -> Self {\n        Self { cli_options: None, workspace_path }\n    }\n\n    pub fn with_cli_options(mut self, cli_options: CliOptions) -> Self {\n        self.cli_options = Some(cli_options);\n        self\n    }\n\n    async fn resolve_universal_vite_config(&self) -> anyhow::Result<ResolvedUniversalViteConfig> {\n        let cli_options = self\n            .cli_options\n            .as_ref()\n            .ok_or_else(|| anyhow::anyhow!(\"CLI options required for vite config resolution\"))?;\n        let workspace_path_str = self\n            .workspace_path\n            .as_path()\n            .to_str()\n            .ok_or_else(|| anyhow::anyhow!(\"workspace path is not valid UTF-8\"))?;\n        let vite_config_json =\n            (cli_options.resolve_universal_vite_config)(workspace_path_str.to_string()).await?;\n\n        Ok(serde_json::from_str(&vite_config_json).inspect_err(|_| {\n            tracing::error!(\"Failed to parse vite config: {vite_config_json}\");\n        })?)\n    }\n\n    /// Resolve a synthesizable subcommand to a concrete program, args, cache config, and envs.\n    async fn resolve(\n        &self,\n        subcommand: SynthesizableSubcommand,\n        resolved_vite_config: Option<&ResolvedUniversalViteConfig>,\n        envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n        cwd: &Arc<AbsolutePath>,\n    ) -> anyhow::Result<ResolvedSubcommand> {\n        match subcommand {\n            SynthesizableSubcommand::Lint { mut args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for lint command\"))?;\n                let resolved = (cli_options.lint)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"lint JS path is not valid UTF-8\"))?;\n                let owned_resolved_vite_config;\n                let resolved_vite_config = if let Some(config) = resolved_vite_config {\n                    config\n                } else {\n                    owned_resolved_vite_config = self.resolve_universal_vite_config().await?;\n                    &owned_resolved_vite_config\n                };\n\n                if let (Some(_), Some(config_file)) =\n                    (&resolved_vite_config.lint, &resolved_vite_config.config_file)\n                {\n                    args.insert(0, \"-c\".to_string());\n                    args.insert(1, config_file.clone());\n                }\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(\"--disable-warning=MODULE_TYPELESS_PACKAGE_JSON\"))\n                        .chain(iter::once(Str::from(js_path_str)))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: Some(Box::new([Str::from(\"OXLINT_TSGOLINT_PATH\")])),\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merge_resolved_envs(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Fmt { mut args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for fmt command\"))?;\n                let resolved = (cli_options.fmt)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"fmt JS path is not valid UTF-8\"))?;\n                let owned_resolved_vite_config;\n                let resolved_vite_config = if let Some(config) = resolved_vite_config {\n                    config\n                } else {\n                    owned_resolved_vite_config = self.resolve_universal_vite_config().await?;\n                    &owned_resolved_vite_config\n                };\n\n                if let (Some(_), Some(config_file)) =\n                    (&resolved_vite_config.fmt, &resolved_vite_config.config_file)\n                {\n                    args.insert(0, \"-c\".to_string());\n                    args.insert(1, config_file.clone());\n                }\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: None,\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merge_resolved_envs(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Build { args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for build command\"))?;\n                let resolved = (cli_options.vite)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"vite JS path is not valid UTF-8\"))?;\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str))\n                        .chain(iter::once(Str::from(\"build\")))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: Some(Box::new([Str::from(\"VITE_*\")])),\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merge_resolved_envs_with_version(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Test { args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for test command\"))?;\n                let resolved = (cli_options.test)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"test JS path is not valid UTF-8\"))?;\n                let prepend_run = should_prepend_vitest_run(&args);\n                let vitest_args: Vec<Str> = if prepend_run {\n                    iter::once(Str::from(\"run\")).chain(args.into_iter().map(Str::from)).collect()\n                } else {\n                    args.into_iter().map(Str::from).collect()\n                };\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str)).chain(vitest_args).collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: None,\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merge_resolved_envs_with_version(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Pack { args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for pack command\"))?;\n                let resolved = (cli_options.pack)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"pack JS path is not valid UTF-8\"))?;\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: None,\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merge_resolved_envs(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Dev { args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for dev command\"))?;\n                let resolved = (cli_options.vite)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"vite JS path is not valid UTF-8\"))?;\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str))\n                        .chain(iter::once(Str::from(\"dev\")))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::disabled(),\n                    envs: merge_resolved_envs_with_version(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Preview { args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for preview command\"))?;\n                let resolved = (cli_options.vite)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"vite JS path is not valid UTF-8\"))?;\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str))\n                        .chain(iter::once(Str::from(\"preview\")))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::disabled(),\n                    envs: merge_resolved_envs_with_version(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Doc { args } => {\n                let cli_options = self\n                    .cli_options\n                    .as_ref()\n                    .ok_or_else(|| anyhow::anyhow!(\"CLI options required for doc command\"))?;\n                let resolved = (cli_options.doc)().await?;\n                let js_path = resolved.bin_path;\n                let js_path_str = js_path\n                    .to_str()\n                    .ok_or_else(|| anyhow::anyhow!(\"doc JS path is not valid UTF-8\"))?;\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::from(OsStr::new(\"node\")),\n                    args: iter::once(Str::from(js_path_str))\n                        .chain(args.into_iter().map(Str::from))\n                        .collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: None,\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merge_resolved_envs(envs, resolved.envs),\n                })\n            }\n            SynthesizableSubcommand::Check { .. } => {\n                anyhow::bail!(\n                    \"Check is a composite command and cannot be resolved to a single subcommand\"\n                );\n            }\n            SynthesizableSubcommand::Install { args } => {\n                let package_manager =\n                    vite_install::PackageManager::builder(cwd).build_with_default().await?;\n                let resolve_command = package_manager.resolve_install_command(&args);\n\n                let merged_envs = {\n                    let mut env_map = FxHashMap::clone(envs);\n                    for (k, v) in resolve_command.envs {\n                        env_map.insert(Arc::from(OsStr::new(&k)), Arc::from(OsStr::new(&v)));\n                    }\n                    Arc::new(env_map)\n                };\n\n                Ok(ResolvedSubcommand {\n                    program: Arc::<OsStr>::from(\n                        OsStr::new(&resolve_command.bin_path).to_os_string(),\n                    ),\n                    args: resolve_command.args.into_iter().map(Str::from).collect(),\n                    cache_config: UserCacheConfig::with_config(EnabledCacheConfig {\n                        env: None,\n                        untracked_env: None,\n                        input: None,\n                    }),\n                    envs: merged_envs,\n                })\n            }\n        }\n    }\n}\n\n/// Merge resolved environment variables from JS resolver into existing envs.\n/// Does not override existing entries.\nfn merge_resolved_envs(\n    envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n    resolved_envs: Vec<(String, String)>,\n) -> Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>> {\n    let mut envs = FxHashMap::clone(envs);\n    for (k, v) in resolved_envs {\n        envs.entry(Arc::from(OsStr::new(&k))).or_insert_with(|| Arc::from(OsStr::new(&v)));\n    }\n    Arc::new(envs)\n}\n\n/// Merge resolved envs and inject VITE_PLUS_VERSION for rolldown-vite branding.\nfn merge_resolved_envs_with_version(\n    envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n    resolved_envs: Vec<(String, String)>,\n) -> Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>> {\n    let mut merged = merge_resolved_envs(envs, resolved_envs);\n    let map = Arc::make_mut(&mut merged);\n    map.entry(Arc::from(OsStr::new(\"VITE_PLUS_VERSION\")))\n        .or_insert_with(|| Arc::from(OsStr::new(env!(\"CARGO_PKG_VERSION\"))));\n    merged\n}\n\n/// CommandHandler implementation for vite-plus.\n/// Handles `vp` commands in task scripts.\npub struct VitePlusCommandHandler {\n    resolver: SubcommandResolver,\n}\n\nimpl std::fmt::Debug for VitePlusCommandHandler {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"VitePlusCommandHandler\").finish()\n    }\n}\n\nimpl VitePlusCommandHandler {\n    pub fn new(resolver: SubcommandResolver) -> Self {\n        Self { resolver }\n    }\n}\n\n#[async_trait::async_trait(?Send)]\nimpl CommandHandler for VitePlusCommandHandler {\n    async fn handle_command(\n        &mut self,\n        command: &mut ScriptCommand,\n    ) -> anyhow::Result<HandledCommand> {\n        // Intercept both \"vp\" and \"vite\" commands in task scripts.\n        // \"vp\" is the conventional alias used in vite-plus task configs.\n        // \"vite\" must also be intercepted so that `vite test`, `vite build`, etc.\n        // in task scripts are synthesized in-session rather than spawning a new CLI process.\n        let program = command.program.as_str();\n        if program != \"vp\" && program != \"vite\" {\n            return Ok(HandledCommand::Verbatim);\n        }\n        // Parse \"vp <args>\" using CLIArgs — always use \"vp\" as the program name\n        // so clap shows \"Usage: vp ...\" even if the original command was \"vite ...\"\n        let cli_args = match CLIArgs::try_parse_from(\n            iter::once(\"vp\").chain(command.args.iter().map(Str::as_str)),\n        ) {\n            Ok(args) => args,\n            Err(err) if err.kind() == ErrorKind::InvalidSubcommand => {\n                return Ok(HandledCommand::Synthesized(\n                    command.to_synthetic_plan_request(UserCacheConfig::disabled()),\n                ));\n            }\n            Err(err) => return Err(err.into()),\n        };\n        match cli_args {\n            CLIArgs::Synthesizable(SynthesizableSubcommand::Check { .. }) => {\n                // Check is a composite command — run as a subprocess in task scripts\n                Ok(HandledCommand::Synthesized(\n                    command.to_synthetic_plan_request(UserCacheConfig::disabled()),\n                ))\n            }\n            CLIArgs::Synthesizable(subcmd) => {\n                let resolved =\n                    self.resolver.resolve(subcmd, None, &command.envs, &command.cwd).await?;\n                Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request()))\n            }\n            CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)),\n            CLIArgs::Exec(_) => {\n                // exec in task scripts should run as a subprocess\n                Ok(HandledCommand::Synthesized(\n                    command.to_synthetic_plan_request(UserCacheConfig::disabled()),\n                ))\n            }\n        }\n    }\n}\n\n/// User config loader that resolves vite.config.ts via JavaScript callback\npub struct VitePlusConfigLoader {\n    resolve_fn: ViteConfigResolverFn,\n}\n\nimpl VitePlusConfigLoader {\n    pub fn new(resolve_fn: ViteConfigResolverFn) -> Self {\n        Self { resolve_fn }\n    }\n}\n\nimpl std::fmt::Debug for VitePlusConfigLoader {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"VitePlusConfigLoader\").finish()\n    }\n}\n\n#[async_trait::async_trait(?Send)]\nimpl UserConfigLoader for VitePlusConfigLoader {\n    async fn load_user_config_file(\n        &self,\n        package_path: &AbsolutePath,\n    ) -> anyhow::Result<Option<UserRunConfig>> {\n        // Try static config extraction first (no JS runtime needed)\n        let static_fields = vite_static_config::resolve_static_config(package_path);\n        match static_fields.get(\"run\") {\n            Some(vite_static_config::FieldValue::Json(run_value)) => {\n                tracing::debug!(\n                    \"Using statically extracted run config for {}\",\n                    package_path.as_path().display()\n                );\n                let run_config: UserRunConfig = serde_json::from_value(run_value)?;\n                return Ok(Some(run_config));\n            }\n            Some(vite_static_config::FieldValue::NonStatic) => {\n                // `run` field exists (or may exist via a spread) — fall back to NAPI\n                tracing::debug!(\n                    \"run config is not statically analyzable for {}, falling back to NAPI\",\n                    package_path.as_path().display()\n                );\n            }\n            None => {\n                // Config was analyzed successfully and `run` field is definitively absent\n                return Ok(None);\n            }\n        }\n\n        // Fall back to NAPI-based config resolution\n        let package_path_str = package_path\n            .as_path()\n            .to_str()\n            .ok_or_else(|| anyhow::anyhow!(\"package path is not valid UTF-8\"))?;\n\n        let config_json = (self.resolve_fn)(package_path_str.to_string()).await?;\n        let resolved: ResolvedUniversalViteConfig = serde_json::from_str(&config_json)\n            .inspect_err(|_| {\n                tracing::error!(\"Failed to parse vite config: {config_json}\");\n            })?;\n\n        let run_config = match resolved.run {\n            Some(run) => serde_json::from_value(run)?,\n            None => UserRunConfig::default(),\n        };\n        Ok(Some(run_config))\n    }\n}\n\n/// Resolve a subcommand into a prepared `tokio::process::Command`.\nasync fn resolve_and_build_command(\n    resolver: &SubcommandResolver,\n    subcommand: SynthesizableSubcommand,\n    resolved_vite_config: Option<&ResolvedUniversalViteConfig>,\n    envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n    cwd: &AbsolutePathBuf,\n    cwd_arc: &Arc<AbsolutePath>,\n) -> Result<tokio::process::Command, Error> {\n    let resolved = resolver\n        .resolve(subcommand, resolved_vite_config, envs, cwd_arc)\n        .await\n        .map_err(|e| Error::Anyhow(e))?;\n\n    // Resolve the program path using `which` to handle Windows .cmd/.bat files (PATHEXT)\n    let program_path = {\n        let paths = resolved.envs.iter().find_map(|(k, v)| {\n            let is_path = if cfg!(windows) {\n                k.as_ref().eq_ignore_ascii_case(\"PATH\")\n            } else {\n                k.as_ref() == \"PATH\"\n            };\n            if is_path { Some(v.as_ref().to_os_string()) } else { None }\n        });\n        vite_command::resolve_bin(\n            resolved.program.as_ref().to_str().unwrap_or_default(),\n            paths.as_deref(),\n            cwd,\n        )?\n    };\n\n    let mut cmd = vite_command::build_command(&program_path, cwd);\n    cmd.args(resolved.args.iter().map(|s| s.as_str()))\n        .env_clear()\n        .envs(resolved.envs.iter().map(|(k, v)| (k.as_ref(), v.as_ref())));\n    Ok(cmd)\n}\n\n/// Resolve a single subcommand and execute it, returning its exit status.\nasync fn resolve_and_execute(\n    resolver: &SubcommandResolver,\n    subcommand: SynthesizableSubcommand,\n    resolved_vite_config: Option<&ResolvedUniversalViteConfig>,\n    envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n    cwd: &AbsolutePathBuf,\n    cwd_arc: &Arc<AbsolutePath>,\n) -> Result<ExitStatus, Error> {\n    let mut cmd =\n        resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)\n            .await?;\n    let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;\n    let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;\n    Ok(ExitStatus(status.code().unwrap_or(1) as u8))\n}\n\n/// Like `resolve_and_execute`, but captures stdout, applies a text filter,\n/// and writes the result to real stdout. Stderr remains inherited.\nasync fn resolve_and_execute_with_stdout_filter(\n    resolver: &SubcommandResolver,\n    subcommand: SynthesizableSubcommand,\n    resolved_vite_config: Option<&ResolvedUniversalViteConfig>,\n    envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n    cwd: &AbsolutePathBuf,\n    cwd_arc: &Arc<AbsolutePath>,\n    filter: impl Fn(&str) -> Cow<'_, str>,\n) -> Result<ExitStatus, Error> {\n    let mut cmd =\n        resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)\n            .await?;\n    cmd.stdout(Stdio::piped());\n\n    let child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;\n    let output = child.wait_with_output().await.map_err(|e| Error::Anyhow(e.into()))?;\n\n    use std::io::Write;\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let filtered = filter(&stdout);\n    let _ = std::io::stdout().lock().write_all(filtered.as_bytes());\n\n    Ok(ExitStatus(output.status.code().unwrap_or(1) as u8))\n}\n\nstruct CapturedCommandOutput {\n    status: ExitStatus,\n    stdout: String,\n    stderr: String,\n}\n\nasync fn resolve_and_capture_output(\n    resolver: &SubcommandResolver,\n    subcommand: SynthesizableSubcommand,\n    resolved_vite_config: Option<&ResolvedUniversalViteConfig>,\n    envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,\n    cwd: &AbsolutePathBuf,\n    cwd_arc: &Arc<AbsolutePath>,\n    force_color_if_terminal: bool,\n) -> Result<CapturedCommandOutput, Error> {\n    let mut cmd =\n        resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)\n            .await?;\n    cmd.stdout(Stdio::piped());\n    cmd.stderr(Stdio::piped());\n    if force_color_if_terminal && std::io::stdout().is_terminal() {\n        cmd.env(\"FORCE_COLOR\", \"1\");\n    }\n\n    let child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;\n    let output = child.wait_with_output().await.map_err(|e| Error::Anyhow(e.into()))?;\n\n    Ok(CapturedCommandOutput {\n        status: ExitStatus(output.status.code().unwrap_or(1) as u8),\n        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),\n        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),\n    })\n}\n\n#[derive(Debug, Clone)]\nstruct CheckSummary {\n    duration: String,\n    files: usize,\n    threads: usize,\n}\n\n#[derive(Debug)]\nstruct FmtSuccess {\n    summary: CheckSummary,\n}\n\n#[derive(Debug)]\nstruct FmtFailure {\n    summary: CheckSummary,\n    issue_files: Vec<String>,\n    issue_count: usize,\n}\n\n#[derive(Debug)]\nstruct LintSuccess {\n    summary: CheckSummary,\n}\n\n#[derive(Debug)]\nstruct LintFailure {\n    summary: CheckSummary,\n    warnings: usize,\n    errors: usize,\n    diagnostics: String,\n}\n\n#[derive(Clone, Copy, Debug, Eq, PartialEq)]\nenum LintMessageKind {\n    LintOnly,\n    LintAndTypeCheck,\n}\n\nimpl LintMessageKind {\n    fn from_lint_config(lint_config: Option<&serde_json::Value>) -> Self {\n        let type_check_enabled = lint_config\n            .and_then(|config| config.get(\"options\"))\n            .and_then(|options| options.get(\"typeCheck\"))\n            .and_then(serde_json::Value::as_bool)\n            .unwrap_or(false);\n\n        if type_check_enabled { Self::LintAndTypeCheck } else { Self::LintOnly }\n    }\n\n    fn success_label(self) -> &'static str {\n        match self {\n            Self::LintOnly => \"Found no warnings or lint errors\",\n            Self::LintAndTypeCheck => \"Found no warnings, lint errors, or type errors\",\n        }\n    }\n\n    fn warning_heading(self) -> &'static str {\n        match self {\n            Self::LintOnly => \"Lint warnings found\",\n            Self::LintAndTypeCheck => \"Lint or type warnings found\",\n        }\n    }\n\n    fn issue_heading(self) -> &'static str {\n        match self {\n            Self::LintOnly => \"Lint issues found\",\n            Self::LintAndTypeCheck => \"Lint or type issues found\",\n        }\n    }\n}\n\nfn parse_check_summary(line: &str) -> Option<CheckSummary> {\n    let rest = line.strip_prefix(\"Finished in \")?;\n    let (duration, rest) = rest.split_once(\" on \")?;\n    let files = rest.split_once(\" file\")?.0.parse().ok()?;\n    let (_, threads_part) = rest.rsplit_once(\" using \")?;\n    let threads = threads_part.split_once(\" thread\")?.0.parse().ok()?;\n\n    Some(CheckSummary { duration: duration.to_string(), files, threads })\n}\n\nfn parse_issue_count(line: &str, prefix: &str) -> Option<usize> {\n    let rest = line.strip_prefix(prefix)?;\n    rest.split_once(\" file\")?.0.parse().ok()\n}\n\nfn parse_warning_error_counts(line: &str) -> Option<(usize, usize)> {\n    let rest = line.strip_prefix(\"Found \")?;\n    let (warnings, rest) = rest.split_once(\" warning\")?;\n    let (_, rest) = rest.split_once(\" and \")?;\n    let errors = rest.split_once(\" error\")?.0;\n    Some((warnings.parse().ok()?, errors.parse().ok()?))\n}\n\nfn format_elapsed(elapsed: std::time::Duration) -> String {\n    if elapsed.as_millis() < 1000 {\n        format!(\"{}ms\", elapsed.as_millis())\n    } else {\n        format!(\"{:.1}s\", elapsed.as_secs_f64())\n    }\n}\n\nfn format_count(count: usize, singular: &str, plural: &str) -> String {\n    if count == 1 { format!(\"1 {singular}\") } else { format!(\"{count} {plural}\") }\n}\n\nfn print_stdout_block(block: &str) {\n    let trimmed = block.trim_matches('\\n');\n    if trimmed.is_empty() {\n        return;\n    }\n\n    use std::io::Write;\n    let mut stdout = std::io::stdout().lock();\n    let _ = stdout.write_all(trimmed.as_bytes());\n    let _ = stdout.write_all(b\"\\n\");\n}\n\nfn print_summary_line(message: &str) {\n    output::raw(\"\");\n    if std::io::stdout().is_terminal() && message.contains('`') {\n        let mut formatted = String::with_capacity(message.len());\n        let mut segments = message.split('`');\n        if let Some(first) = segments.next() {\n            formatted.push_str(first);\n        }\n        let mut is_accent = true;\n        for segment in segments {\n            if is_accent {\n                formatted.push_str(&format!(\"{}\", format!(\"`{segment}`\").bright_blue()));\n            } else {\n                formatted.push_str(segment);\n            }\n            is_accent = !is_accent;\n        }\n        output::raw(&formatted);\n    } else {\n        output::raw(message);\n    }\n}\n\nfn print_error_block(error_msg: &str, combined_output: &str, summary_msg: &str) {\n    output::error(error_msg);\n    if !combined_output.trim().is_empty() {\n        print_stdout_block(combined_output);\n    }\n    print_summary_line(summary_msg);\n}\n\nfn print_pass_line(message: &str, detail: Option<&str>) {\n    if let Some(detail) = detail {\n        output::raw(&format!(\"{} {message} {}\", \"pass:\".bright_blue().bold(), detail.dimmed()));\n    } else {\n        output::pass(message);\n    }\n}\n\nfn analyze_fmt_check_output(output: &str) -> Option<Result<FmtSuccess, FmtFailure>> {\n    let trimmed = output.trim();\n    if trimmed.is_empty() {\n        return None;\n    }\n\n    let lines: Vec<&str> = trimmed.lines().collect();\n    let finish_line = lines.iter().rev().find(|line| line.starts_with(\"Finished in \"))?;\n    let summary = parse_check_summary(finish_line)?;\n\n    if lines.iter().any(|line| *line == \"All matched files use the correct format.\") {\n        return Some(Ok(FmtSuccess { summary }));\n    }\n\n    let issue_line = lines.iter().find(|line| line.starts_with(\"Format issues found in above \"))?;\n    let issue_count = parse_issue_count(issue_line, \"Format issues found in above \")?;\n\n    let mut issue_files = Vec::new();\n    let mut collecting = false;\n    for line in lines {\n        if line == \"Checking formatting...\" {\n            collecting = true;\n            continue;\n        }\n        if !collecting {\n            continue;\n        }\n        if line.is_empty() {\n            continue;\n        }\n        if line.starts_with(\"Format issues found in above \") || line.starts_with(\"Finished in \") {\n            break;\n        }\n        issue_files.push(line.to_string());\n    }\n\n    Some(Err(FmtFailure { summary, issue_files, issue_count }))\n}\n\nfn analyze_lint_output(output: &str) -> Option<Result<LintSuccess, LintFailure>> {\n    let trimmed = output.trim();\n    if trimmed.is_empty() {\n        return None;\n    }\n\n    let lines: Vec<&str> = trimmed.lines().collect();\n    let counts_idx = lines.iter().position(|line| {\n        line.starts_with(\"Found \") && line.contains(\" warning\") && line.contains(\" error\")\n    })?;\n    let finish_line =\n        lines.iter().skip(counts_idx + 1).find(|line| line.starts_with(\"Finished in \"))?;\n\n    let summary = parse_check_summary(finish_line)?;\n    let (warnings, errors) = parse_warning_error_counts(lines[counts_idx])?;\n    let diagnostics = lines[..counts_idx].join(\"\\n\").trim_matches('\\n').to_string();\n\n    if warnings == 0 && errors == 0 {\n        return Some(Ok(LintSuccess { summary }));\n    }\n\n    Some(Err(LintFailure { summary, warnings, errors, diagnostics }))\n}\n\n/// Execute a synthesizable subcommand directly (not through vite-task Session).\n/// No caching, no task graph, no dependency resolution.\nasync fn execute_direct_subcommand(\n    subcommand: SynthesizableSubcommand,\n    cwd: &AbsolutePathBuf,\n    options: Option<CliOptions>,\n) -> Result<ExitStatus, Error> {\n    let (workspace_root, _) = vite_workspace::find_workspace_root(cwd)?;\n    let workspace_path: Arc<AbsolutePath> = workspace_root.path.into();\n\n    let resolver = if let Some(options) = options {\n        SubcommandResolver::new(Arc::clone(&workspace_path)).with_cli_options(options)\n    } else {\n        SubcommandResolver::new(Arc::clone(&workspace_path))\n    };\n\n    let envs: Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>> = Arc::new(\n        std::env::vars_os()\n            .map(|(k, v)| (Arc::from(k.as_os_str()), Arc::from(v.as_os_str())))\n            .collect(),\n    );\n    let cwd_arc: Arc<AbsolutePath> = cwd.clone().into();\n\n    let status = match subcommand {\n        SynthesizableSubcommand::Check { fix, no_fmt, no_lint, paths } => {\n            if no_fmt && no_lint {\n                output::error(\"No checks enabled\");\n                print_summary_line(\n                    \"`vp check` did not run because both `--no-fmt` and `--no-lint` were set\",\n                );\n                return Ok(ExitStatus(1));\n            }\n\n            let mut status = ExitStatus::SUCCESS;\n            let has_paths = !paths.is_empty();\n            let mut fmt_fix_started: Option<Instant> = None;\n            let mut deferred_lint_pass: Option<(String, String)> = None;\n            let resolved_vite_config = resolver.resolve_universal_vite_config().await?;\n\n            if !no_fmt {\n                let mut args = if fix { vec![] } else { vec![\"--check\".to_string()] };\n                if has_paths {\n                    args.push(\"--no-error-on-unmatched-pattern\".to_string());\n                    args.extend(paths.iter().cloned());\n                }\n                let fmt_start = Instant::now();\n                if fix {\n                    fmt_fix_started = Some(fmt_start);\n                }\n                let captured = resolve_and_capture_output(\n                    &resolver,\n                    SynthesizableSubcommand::Fmt { args },\n                    Some(&resolved_vite_config),\n                    &envs,\n                    cwd,\n                    &cwd_arc,\n                    false,\n                )\n                .await?;\n                status = captured.status;\n\n                let combined_output = if captured.stderr.is_empty() {\n                    captured.stdout\n                } else if captured.stdout.is_empty() {\n                    captured.stderr\n                } else {\n                    format!(\"{}{}\", captured.stdout, captured.stderr)\n                };\n\n                if !fix {\n                    match analyze_fmt_check_output(&combined_output) {\n                        Some(Ok(success)) => print_pass_line(\n                            &format!(\n                                \"All {} are correctly formatted\",\n                                format_count(success.summary.files, \"file\", \"files\")\n                            ),\n                            Some(&format!(\n                                \"({}, {} threads)\",\n                                success.summary.duration, success.summary.threads\n                            )),\n                        ),\n                        Some(Err(failure)) => {\n                            output::error(\"Formatting issues found\");\n                            print_stdout_block(&failure.issue_files.join(\"\\n\"));\n                            print_summary_line(&format!(\n                                \"Found formatting issues in {} ({}, {} threads). Run `vp check --fix` to fix them.\",\n                                format_count(failure.issue_count, \"file\", \"files\"),\n                                failure.summary.duration,\n                                failure.summary.threads\n                            ));\n                        }\n                        None => {\n                            print_error_block(\n                                \"Formatting could not start\",\n                                &combined_output,\n                                \"Formatting failed before analysis started\",\n                            );\n                        }\n                    }\n                }\n\n                if fix && no_lint && status == ExitStatus::SUCCESS {\n                    print_pass_line(\n                        \"Formatting completed for checked files\",\n                        Some(&format!(\"({})\", format_elapsed(fmt_start.elapsed()))),\n                    );\n                }\n                if status != ExitStatus::SUCCESS {\n                    if fix {\n                        print_error_block(\n                            \"Formatting could not complete\",\n                            &combined_output,\n                            \"Formatting failed during fix\",\n                        );\n                    }\n                    return Ok(status);\n                }\n            }\n\n            if !no_lint {\n                let lint_message_kind =\n                    LintMessageKind::from_lint_config(resolved_vite_config.lint.as_ref());\n                let mut args = Vec::new();\n                if fix {\n                    args.push(\"--fix\".to_string());\n                }\n                // `vp check` parses oxlint's human-readable summary output to print\n                // unified pass/fail lines. When `GITHUB_ACTIONS=true`, oxlint auto-switches\n                // to the GitHub reporter, which omits that summary on success and makes the\n                // parser think linting never started. Force the default reporter here so the\n                // captured output is stable across local and CI environments.\n                args.push(\"--format=default\".to_string());\n                if has_paths {\n                    args.extend(paths.iter().cloned());\n                }\n                let captured = resolve_and_capture_output(\n                    &resolver,\n                    SynthesizableSubcommand::Lint { args },\n                    Some(&resolved_vite_config),\n                    &envs,\n                    cwd,\n                    &cwd_arc,\n                    true,\n                )\n                .await?;\n                status = captured.status;\n\n                let combined_output = if captured.stderr.is_empty() {\n                    captured.stdout\n                } else if captured.stdout.is_empty() {\n                    captured.stderr\n                } else {\n                    format!(\"{}{}\", captured.stdout, captured.stderr)\n                };\n\n                match analyze_lint_output(&combined_output) {\n                    Some(Ok(success)) => {\n                        let message = format!(\n                            \"{} in {}\",\n                            lint_message_kind.success_label(),\n                            format_count(success.summary.files, \"file\", \"files\"),\n                        );\n                        let detail = format!(\n                            \"({}, {} threads)\",\n                            success.summary.duration, success.summary.threads\n                        );\n\n                        if fix && !no_fmt {\n                            deferred_lint_pass = Some((message, detail));\n                        } else {\n                            print_pass_line(&message, Some(&detail));\n                        }\n                    }\n                    Some(Err(failure)) => {\n                        if failure.errors == 0 && failure.warnings > 0 {\n                            output::warn(lint_message_kind.warning_heading());\n                        } else {\n                            output::error(lint_message_kind.issue_heading());\n                        }\n                        print_stdout_block(&failure.diagnostics);\n                        print_summary_line(&format!(\n                            \"Found {} and {} in {} ({}, {} threads)\",\n                            format_count(failure.errors, \"error\", \"errors\"),\n                            format_count(failure.warnings, \"warning\", \"warnings\"),\n                            format_count(failure.summary.files, \"file\", \"files\"),\n                            failure.summary.duration,\n                            failure.summary.threads\n                        ));\n                    }\n                    None => {\n                        output::error(\"Linting could not start\");\n                        if !combined_output.trim().is_empty() {\n                            print_stdout_block(&combined_output);\n                        }\n                        print_summary_line(\"Linting failed before analysis started\");\n                    }\n                }\n                if status != ExitStatus::SUCCESS {\n                    return Ok(status);\n                }\n            }\n\n            // Re-run fmt after lint --fix, since lint fixes can break formatting\n            // (e.g. the curly rule adding braces to if-statements)\n            if fix && !no_fmt && !no_lint {\n                let mut args = Vec::new();\n                if has_paths {\n                    args.push(\"--no-error-on-unmatched-pattern\".to_string());\n                    args.extend(paths.into_iter());\n                }\n                let captured = resolve_and_capture_output(\n                    &resolver,\n                    SynthesizableSubcommand::Fmt { args },\n                    Some(&resolved_vite_config),\n                    &envs,\n                    cwd,\n                    &cwd_arc,\n                    false,\n                )\n                .await?;\n                status = captured.status;\n                if status != ExitStatus::SUCCESS {\n                    let combined_output = if captured.stderr.is_empty() {\n                        captured.stdout\n                    } else if captured.stdout.is_empty() {\n                        captured.stderr\n                    } else {\n                        format!(\"{}{}\", captured.stdout, captured.stderr)\n                    };\n                    print_error_block(\n                        \"Formatting could not finish after lint fixes\",\n                        &combined_output,\n                        \"Formatting failed after lint fixes were applied\",\n                    );\n                    return Ok(status);\n                }\n                if let Some(started) = fmt_fix_started {\n                    print_pass_line(\n                        \"Formatting completed for checked files\",\n                        Some(&format!(\"({})\", format_elapsed(started.elapsed()))),\n                    );\n                }\n                if let Some((message, detail)) = deferred_lint_pass.take() {\n                    print_pass_line(&message, Some(&detail));\n                }\n            }\n\n            status\n        }\n        other => {\n            if should_suppress_subcommand_stdout(&other) {\n                resolve_and_execute_with_stdout_filter(\n                    &resolver,\n                    other,\n                    None,\n                    &envs,\n                    cwd,\n                    &cwd_arc,\n                    |_| Cow::Borrowed(\"\"),\n                )\n                .await?\n            } else {\n                resolve_and_execute(&resolver, other, None, &envs, cwd, &cwd_arc).await?\n            }\n        }\n    };\n\n    Ok(status)\n}\n\n/// Execute a vite-task command (run, cache) through Session.\nasync fn execute_vite_task_command(\n    command: Command,\n    cwd: AbsolutePathBuf,\n    options: Option<CliOptions>,\n) -> Result<ExitStatus, Error> {\n    let (workspace_root, _) = vite_workspace::find_workspace_root(&cwd)?;\n    let workspace_path: Arc<AbsolutePath> = workspace_root.path.into();\n\n    let resolve_vite_config_fn = options\n        .as_ref()\n        .map(|o| Arc::clone(&o.resolve_universal_vite_config))\n        .ok_or_else(|| {\n            Error::Anyhow(anyhow::anyhow!(\n                \"resolve_universal_vite_config is required but not available\"\n            ))\n        })?;\n\n    let resolver = if let Some(options) = options {\n        SubcommandResolver::new(Arc::clone(&workspace_path)).with_cli_options(options)\n    } else {\n        SubcommandResolver::new(Arc::clone(&workspace_path))\n    };\n\n    let mut command_handler = VitePlusCommandHandler::new(resolver);\n    let mut config_loader = VitePlusConfigLoader::new(resolve_vite_config_fn);\n\n    // Update PATH to include package manager bin directory BEFORE session init\n    if let Ok(pm) = vite_install::PackageManager::builder(&cwd).build().await {\n        let bin_prefix = pm.get_bin_prefix();\n        prepend_to_path_env(&bin_prefix, PrependOptions::default());\n    }\n\n    let session = Session::init(SessionConfig {\n        command_handler: &mut command_handler,\n        user_config_loader: &mut config_loader,\n        program_name: Str::from(\"vp\"),\n    })?;\n\n    // Main execution (consumes session)\n    let result = session.main(command).await.map_err(|e| Error::Anyhow(e));\n\n    result\n}\n\n/// Main entry point for vite-plus CLI.\n///\n/// # Arguments\n/// * `cwd` - Current working directory\n/// * `options` - Optional CLI options with resolver functions\n/// * `args` - Optional CLI arguments. If None, uses env::args(). This allows NAPI bindings\n///            to pass process.argv.slice(2) to avoid including node binary and script path.\n#[tracing::instrument(skip(options))]\npub async fn main(\n    cwd: AbsolutePathBuf,\n    options: Option<CliOptions>,\n    args: Option<Vec<String>>,\n) -> Result<ExitStatus, Error> {\n    let args_vec: Vec<String> = args.unwrap_or_else(|| env::args().skip(1).collect());\n    let args_vec = normalize_help_args(args_vec);\n    if should_print_help(&args_vec) {\n        print_help();\n        return Ok(ExitStatus::SUCCESS);\n    }\n\n    let args_with_program = std::iter::once(\"vp\".to_string()).chain(args_vec.iter().cloned());\n    let cli_args = match CLIArgs::try_parse_from(args_with_program) {\n        Ok(args) => args,\n        Err(err) => return handle_cli_parse_error(err),\n    };\n\n    match cli_args {\n        CLIArgs::Synthesizable(subcmd) => execute_direct_subcommand(subcmd, &cwd, options).await,\n        CLIArgs::ViteTask(command) => execute_vite_task_command(command, cwd, options).await,\n        CLIArgs::Exec(exec_args) => crate::exec::execute(exec_args, &cwd).await,\n    }\n}\n\nfn handle_cli_parse_error(err: clap::Error) -> Result<ExitStatus, Error> {\n    if matches!(err.kind(), ErrorKind::InvalidSubcommand) && print_invalid_subcommand_error(&err) {\n        return Ok(ExitStatus(err.exit_code() as u8));\n    }\n    if matches!(err.kind(), ErrorKind::UnknownArgument) && print_unknown_argument_error(&err) {\n        return Ok(ExitStatus(err.exit_code() as u8));\n    }\n\n    err.print().map_err(|e| Error::Anyhow(e.into()))?;\n    Ok(ExitStatus(err.exit_code() as u8))\n}\n\nfn normalize_help_args(args: Vec<String>) -> Vec<String> {\n    match args.as_slice() {\n        [arg] if arg == \"help\" => vec![\"--help\".to_string()],\n        [first, command, rest @ ..] if first == \"help\" => {\n            let mut normalized = Vec::with_capacity(rest.len() + 2);\n            normalized.push(command.to_string());\n            normalized.push(\"--help\".to_string());\n            normalized.extend(rest.iter().cloned());\n            normalized\n        }\n        _ => args,\n    }\n}\n\nfn is_vitest_help_flag(arg: &str) -> bool {\n    matches!(arg, \"-h\" | \"--help\")\n}\n\nfn is_vitest_watch_flag(arg: &str) -> bool {\n    matches!(arg, \"-w\" | \"--watch\")\n}\n\nfn is_vitest_test_subcommand(arg: &str) -> bool {\n    matches!(arg, \"run\" | \"watch\" | \"dev\" | \"related\" | \"bench\" | \"init\" | \"list\")\n}\n\nfn has_flag_before_terminator(args: &[String], flag: &str) -> bool {\n    for arg in args {\n        if arg == \"--\" {\n            break;\n        }\n        if arg == flag || arg.starts_with(&format!(\"{flag}=\")) {\n            return true;\n        }\n    }\n    false\n}\n\nfn should_suppress_subcommand_stdout(subcommand: &SynthesizableSubcommand) -> bool {\n    match subcommand {\n        SynthesizableSubcommand::Lint { args } => has_flag_before_terminator(args, \"--init\"),\n        SynthesizableSubcommand::Fmt { args } => {\n            has_flag_before_terminator(args, \"--init\")\n                || has_flag_before_terminator(args, \"--migrate\")\n        }\n        _ => false,\n    }\n}\n\nfn should_prepend_vitest_run(args: &[String]) -> bool {\n    let Some(first_arg) = args.first().map(String::as_str) else {\n        return true;\n    };\n\n    if is_vitest_test_subcommand(first_arg) {\n        return false;\n    }\n\n    for arg in args.iter().take_while(|arg| arg.as_str() != \"--\") {\n        let arg = arg.as_str();\n        if is_vitest_help_flag(arg) || is_vitest_watch_flag(arg) || arg == \"--run\" {\n            return false;\n        }\n    }\n\n    true\n}\n\nfn should_print_help(args: &[String]) -> bool {\n    args.is_empty() || matches!(args, [arg] if arg == \"-h\" || arg == \"--help\")\n}\n\nfn extract_invalid_subcommand_details(error: &clap::Error) -> Option<(String, Option<String>)> {\n    let invalid_subcommand = match error.get(ContextKind::InvalidSubcommand) {\n        Some(ContextValue::String(value)) => value.as_str(),\n        _ => return None,\n    };\n\n    let suggestion = match error.get(ContextKind::SuggestedSubcommand) {\n        Some(ContextValue::String(value)) => Some(value.to_owned()),\n        Some(ContextValue::Strings(values)) => {\n            vite_shared::string_similarity::pick_best_suggestion(invalid_subcommand, values)\n        }\n        _ => None,\n    };\n\n    Some((invalid_subcommand.to_owned(), suggestion))\n}\n\nfn print_invalid_subcommand_error(error: &clap::Error) -> bool {\n    let Some((invalid_subcommand, suggestion)) = extract_invalid_subcommand_details(error) else {\n        return false;\n    };\n\n    let highlighted_subcommand = invalid_subcommand.bright_blue().to_string();\n    output::error(&format!(\"Command '{highlighted_subcommand}' not found\"));\n\n    if let Some(suggestion) = suggestion {\n        eprintln!();\n        let highlighted_suggestion = format!(\"`vp {suggestion}`\").bright_blue().to_string();\n        eprintln!(\"Did you mean {highlighted_suggestion}?\");\n    }\n\n    true\n}\n\nfn extract_unknown_argument(error: &clap::Error) -> Option<String> {\n    match error.get(ContextKind::InvalidArg) {\n        Some(ContextValue::String(value)) => Some(value.to_owned()),\n        _ => None,\n    }\n}\n\nfn has_pass_as_value_suggestion(error: &clap::Error) -> bool {\n    let contains_pass_as_value = |suggestion: &str| suggestion.contains(\"as a value\");\n\n    match error.get(ContextKind::Suggested) {\n        Some(ContextValue::String(suggestion)) => contains_pass_as_value(suggestion),\n        Some(ContextValue::Strings(suggestions)) => {\n            suggestions.iter().any(|suggestion| contains_pass_as_value(suggestion))\n        }\n        Some(ContextValue::StyledStr(suggestion)) => {\n            contains_pass_as_value(&suggestion.to_string())\n        }\n        Some(ContextValue::StyledStrs(suggestions)) => {\n            suggestions.iter().any(|suggestion| contains_pass_as_value(&suggestion.to_string()))\n        }\n        _ => false,\n    }\n}\n\nfn print_unknown_argument_error(error: &clap::Error) -> bool {\n    let Some(invalid_argument) = extract_unknown_argument(error) else {\n        return false;\n    };\n\n    let highlighted_argument = invalid_argument.bright_blue().to_string();\n    output::error(&format!(\"Unexpected argument '{highlighted_argument}'\"));\n\n    if has_pass_as_value_suggestion(error) {\n        eprintln!();\n        let pass_through_argument = format!(\"-- {invalid_argument}\");\n        let highlighted_pass_through_argument =\n            format!(\"`{}`\", pass_through_argument.bright_blue());\n        eprintln!(\"Use {highlighted_pass_through_argument} to pass the argument as a value\");\n    }\n\n    true\n}\n\nfn print_help() {\n    let header = vite_shared::header::vite_plus_header();\n    let bold = \"\\x1b[1m\";\n    let bold_underline = \"\\x1b[1;4m\";\n    let reset = \"\\x1b[0m\";\n    println!(\n        \"{header}\n\n{bold_underline}Usage:{reset} {bold}vp{reset} <COMMAND>\n\n{bold_underline}Core Commands:{reset}\n  {bold}dev{reset}            Run the development server\n  {bold}build{reset}          Build for production\n  {bold}test{reset}           Run tests\n  {bold}lint{reset}           Lint code\n  {bold}fmt{reset}            Format code\n  {bold}check{reset}          Run format, lint, and type checks\n  {bold}pack{reset}           Build library\n  {bold}run{reset}            Run tasks\n  {bold}exec{reset}           Execute a command from local node_modules/.bin\n  {bold}preview{reset}        Preview production build\n  {bold}cache{reset}          Manage the task cache\n  {bold}config{reset}         Configure hooks and agent integration\n  {bold}staged{reset}         Run linters on staged files\n\n{bold_underline}Package Manager Commands:{reset}\n  {bold}install{reset}    Install all dependencies, or add packages if package names are provided\n\nOptions:\n  -h, --help  Print help\"\n    );\n}\n\npub use vite_shared::init_tracing;\n\n#[cfg(test)]\nmod tests {\n    use std::path::PathBuf;\n\n    use clap::Parser;\n    use serde_json::json;\n    use vite_task::config::UserRunConfig;\n\n    use super::{\n        CLIArgs, LintMessageKind, SynthesizableSubcommand, extract_unknown_argument,\n        has_pass_as_value_suggestion, should_prepend_vitest_run, should_suppress_subcommand_stdout,\n    };\n\n    #[test]\n    fn run_config_types_in_sync() {\n        // Remove \\r for cross-platform consistency\n        let ts_type = UserRunConfig::TS_TYPE.replace('\\r', \"\");\n        let manifest_dir = std::env::var(\"CARGO_MANIFEST_DIR\").expect(\"CARGO_MANIFEST_DIR not set\");\n        let run_config_path = PathBuf::from(manifest_dir).join(\"../src/run-config.ts\");\n\n        if std::env::var(\"VITE_UPDATE_TASK_TYPES\").as_deref() == Ok(\"1\") {\n            std::fs::write(&run_config_path, &ts_type).expect(\"Failed to write run-config.ts\");\n        } else {\n            let current = std::fs::read_to_string(&run_config_path)\n                .expect(\"Failed to read run-config.ts\")\n                .replace('\\r', \"\");\n            pretty_assertions::assert_eq!(\n                current,\n                ts_type,\n                \"run-config.ts is out of sync. Run `VITE_UPDATE_TASK_TYPES=1 cargo test -p vite-plus-cli run_config_types_in_sync` to update.\"\n            );\n        }\n    }\n\n    #[test]\n    fn unknown_argument_detected_without_pass_as_value_hint() {\n        let error = CLIArgs::try_parse_from([\"vp\", \"--cache\"]).expect_err(\"Expected parse error\");\n        assert_eq!(extract_unknown_argument(&error).as_deref(), Some(\"--cache\"));\n        assert!(!has_pass_as_value_suggestion(&error));\n    }\n\n    #[test]\n    fn unknown_argument_detected_with_pass_as_value_hint() {\n        let error =\n            CLIArgs::try_parse_from([\"vp\", \"run\", \"--yolo\"]).expect_err(\"Expected parse error\");\n        assert_eq!(extract_unknown_argument(&error).as_deref(), Some(\"--yolo\"));\n        assert!(has_pass_as_value_suggestion(&error));\n    }\n\n    #[test]\n    fn test_without_args_defaults_to_run_mode() {\n        assert!(should_prepend_vitest_run(&[]));\n    }\n\n    #[test]\n    fn test_with_filters_defaults_to_run_mode() {\n        assert!(should_prepend_vitest_run(&[\"src/foo.test.ts\".to_string()]));\n    }\n\n    #[test]\n    fn test_with_options_defaults_to_run_mode() {\n        assert!(should_prepend_vitest_run(&[\"--coverage\".to_string()]));\n    }\n\n    #[test]\n    fn test_with_run_subcommand_does_not_prepend_run() {\n        assert!(!should_prepend_vitest_run(&[\"run\".to_string(), \"--coverage\".to_string()]));\n    }\n\n    #[test]\n    fn test_with_watch_subcommand_does_not_prepend_run() {\n        assert!(!should_prepend_vitest_run(&[\"watch\".to_string()]));\n    }\n\n    #[test]\n    fn test_with_watch_flag_does_not_prepend_run() {\n        assert!(!should_prepend_vitest_run(&[\"--watch\".to_string()]));\n        assert!(!should_prepend_vitest_run(&[\"-w\".to_string()]));\n    }\n\n    #[test]\n    fn test_with_help_flag_does_not_prepend_run() {\n        assert!(!should_prepend_vitest_run(&[\"--help\".to_string()]));\n        assert!(!should_prepend_vitest_run(&[\"-h\".to_string()]));\n    }\n\n    #[test]\n    fn test_with_explicit_run_flag_does_not_prepend_run() {\n        assert!(!should_prepend_vitest_run(&[\"--run\".to_string(), \"--coverage\".to_string()]));\n    }\n\n    #[test]\n    fn test_ignores_flags_after_option_terminator() {\n        assert!(should_prepend_vitest_run(&[\n            \"--\".to_string(),\n            \"--watch\".to_string(),\n            \"src/foo.test.ts\".to_string(),\n        ]));\n    }\n\n    #[test]\n    fn lint_init_suppresses_stdout() {\n        let subcommand = SynthesizableSubcommand::Lint { args: vec![\"--init\".to_string()] };\n        assert!(should_suppress_subcommand_stdout(&subcommand));\n    }\n\n    #[test]\n    fn fmt_migrate_suppresses_stdout() {\n        let subcommand =\n            SynthesizableSubcommand::Fmt { args: vec![\"--migrate=prettier\".to_string()] };\n        assert!(should_suppress_subcommand_stdout(&subcommand));\n    }\n\n    #[test]\n    fn normal_lint_does_not_suppress_stdout() {\n        let subcommand = SynthesizableSubcommand::Lint { args: vec![\"src/index.ts\".to_string()] };\n        assert!(!should_suppress_subcommand_stdout(&subcommand));\n    }\n\n    #[test]\n    fn lint_message_kind_defaults_to_lint_only_without_typecheck() {\n        assert_eq!(LintMessageKind::from_lint_config(None), LintMessageKind::LintOnly);\n        assert_eq!(\n            LintMessageKind::from_lint_config(Some(&json!({ \"options\": {} }))),\n            LintMessageKind::LintOnly\n        );\n    }\n\n    #[test]\n    fn lint_message_kind_detects_typecheck_from_vite_config() {\n        let kind = LintMessageKind::from_lint_config(Some(&json!({\n            \"options\": {\n                \"typeAware\": true,\n                \"typeCheck\": true\n            }\n        })));\n\n        assert_eq!(kind, LintMessageKind::LintAndTypeCheck);\n        assert_eq!(kind.success_label(), \"Found no warnings, lint errors, or type errors\");\n        assert_eq!(kind.warning_heading(), \"Lint or type warnings found\");\n        assert_eq!(kind.issue_heading(), \"Lint or type issues found\");\n    }\n\n    #[test]\n    fn global_subcommands_produce_invalid_subcommand_error() {\n        use clap::error::ErrorKind;\n\n        for subcommand in [\"config\", \"create\", \"env\", \"migrate\"] {\n            let error = CLIArgs::try_parse_from([\"vp\", subcommand])\n                .expect_err(&format!(\"expected error for global subcommand '{subcommand}'\"));\n            assert_eq!(\n                error.kind(),\n                ErrorKind::InvalidSubcommand,\n                \"expected InvalidSubcommand for '{subcommand}', got {:?}\",\n                error.kind()\n            );\n        }\n    }\n}\n"
  },
  {
    "path": "packages/cli/binding/src/exec/args.rs",
    "content": "use vite_workspace::package_filter::PackageQueryArgs;\n\n/// Parsed exec arguments (clap-derived).\n#[derive(Debug, clap::Args)]\n#[command(\n    about = \"Execute a command from local node_modules/.bin\",\n    after_help = \"\\\nExamples:\n  vp exec node --version                             # Run local node\n  vp exec tsc --noEmit                               # Run local TypeScript compiler\n  vp exec -c 'tsc --noEmit && prettier --check .'    # Shell mode\n  vp exec -r -- tsc --noEmit                         # Run in all workspace packages\n  vp exec --filter 'app...' -- tsc                   # Run in filtered packages\"\n)]\npub(crate) struct ExecArgs {\n    #[clap(flatten)]\n    pub packages: PackageQueryArgs,\n\n    /// Execute the command within a shell environment\n    #[clap(short = 'c', long = \"shell-mode\")]\n    pub shell_mode: bool,\n\n    /// Run concurrently without topological ordering\n    #[clap(long)]\n    pub parallel: bool,\n\n    /// Reverse execution order\n    #[clap(long)]\n    pub reverse: bool,\n\n    /// Resume from a specific package\n    #[clap(long = \"resume-from\")]\n    pub resume_from: Option<String>,\n\n    /// Save results to vp-exec-summary.json\n    #[clap(long = \"report-summary\")]\n    pub report_summary: bool,\n\n    /// Command and arguments to execute\n    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n    pub command: Vec<String>,\n}\n"
  },
  {
    "path": "packages/cli/binding/src/exec/mod.rs",
    "content": "mod args;\nmod workspace;\n\npub(crate) use args::ExecArgs;\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_task::ExitStatus;\n\nuse self::workspace::execute_exec_workspace;\n\n/// Execute `vp exec` command in the local CLI.\n///\n/// Resolves the workspace, selects packages (defaulting to the current package\n/// when no flags are given), and executes the command in each selected package.\npub async fn execute(exec_args: ExecArgs, cwd: &AbsolutePathBuf) -> Result<ExitStatus, Error> {\n    // No command specified\n    if exec_args.command.is_empty() {\n        vite_shared::output::error(\n            \"'vp exec' requires a command to run\\n\\n\\\n             Usage: vp exec [--] <command> [args...]\\n\\n\\\n             Examples:\\n\\\n             \\x20 vp exec node --version\\n\\\n             \\x20 vp exec tsc --noEmit\",\n        );\n        return Ok(ExitStatus(1));\n    }\n\n    execute_exec_workspace(exec_args, cwd).await\n}\n"
  },
  {
    "path": "packages/cli/binding/src/exec/workspace.rs",
    "content": "use std::{collections::BTreeMap, process::Stdio, sync::Arc};\n\nuse owo_colors::OwoColorize;\nuse petgraph::prelude::DiGraphMap;\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_task::ExitStatus;\nuse vite_workspace::{PackageNodeIndex, package_graph::IndexedPackageGraph};\n\nuse super::args::ExecArgs;\n\n/// Execute `vp exec` across workspace packages.\n///\n/// When no filter flags are given, selects the current package (containing `cwd`).\n/// With `--recursive` or `--filter`, selects matching workspace packages.\npub(super) async fn execute_exec_workspace(\n    args: ExecArgs,\n    cwd: &AbsolutePathBuf,\n) -> Result<ExitStatus, Error> {\n    // Find workspace root and load package graph\n    let (workspace_root, _) =\n        vite_workspace::find_workspace_root(cwd).map_err(|e| Error::Anyhow(e.into()))?;\n    let graph =\n        vite_workspace::load_package_graph(&workspace_root).map_err(|e| Error::Anyhow(e.into()))?;\n\n    // Index the graph for O(1) lookups\n    let indexed = IndexedPackageGraph::index(graph);\n\n    // Build the query from exec flags\n    let cwd_arc: Arc<vite_path::AbsolutePath> = cwd.clone().into();\n    let (query, is_cwd_only) = match args.packages.into_package_query(None, &cwd_arc) {\n        Ok(result) => result,\n        Err(e) => {\n            vite_shared::output::error(&vite_str::format!(\"{e}\"));\n            return Ok(ExitStatus(1));\n        }\n    };\n\n    // Resolve query into a package subgraph\n    let resolution = match indexed.resolve_query(&query) {\n        Ok(result) => result,\n        Err(e) => {\n            vite_shared::output::error(&vite_str::format!(\"{e}\"));\n            return Ok(ExitStatus(1));\n        }\n    };\n\n    // Warn about unmatched selectors\n    for selector in &resolution.unmatched_selectors {\n        vite_shared::output::warn(&vite_str::format!(\n            \"No packages matched the filter '{}'\",\n            selector\n        ));\n    }\n\n    let package_graph = indexed.package_graph();\n    let subgraph = resolution.package_subgraph;\n\n    // Topological sort on the subgraph\n    let mut selected = topological_sort_packages(&subgraph);\n\n    // Apply --reverse: reverse the execution order\n    if args.reverse {\n        selected.reverse();\n    }\n\n    // Apply --resume-from: skip packages until the named one\n    if let Some(ref resume_pkg) = args.resume_from {\n        if let Some(pos) = selected\n            .iter()\n            .position(|&idx| package_graph[idx].package_json.name.as_str() == resume_pkg.as_str())\n        {\n            selected = selected[pos..].to_vec();\n        } else {\n            vite_shared::output::error(&vite_str::format!(\n                \"Package '{}' not found in selected packages\",\n                resume_pkg\n            ));\n            return Ok(ExitStatus(1));\n        }\n    }\n\n    if selected.is_empty() {\n        vite_shared::output::warn(\"No packages matched the filter(s)\");\n        return Ok(ExitStatus::SUCCESS);\n    }\n\n    let single_package = selected.len() == 1;\n    // Suppress the \"pkg_name$ cmd\" prefix when only 1 package is selected\n    let show_prefix = !single_package;\n\n    // When no package-selection flags were set (is_cwd_only), execute from the\n    // caller's exact working directory — not the package root.  This matches\n    // `pnpm exec` behaviour.\n    let use_caller_cwd = is_cwd_only;\n\n    // Build base PATH: <pm_bin>:<workspace_root/node_modules/.bin>:<original_PATH>\n    let base_path_dirs: Vec<std::path::PathBuf> = {\n        let mut dirs = Vec::new();\n        // Include package manager bin dir\n        if let Ok(pm) = vite_install::PackageManager::builder(&*workspace_root.path).build().await {\n            dirs.push(pm.get_bin_prefix().as_path().to_path_buf());\n        }\n        // Include workspace root's node_modules/.bin\n        let ws_bin = workspace_root.path.join(\"node_modules\").join(\".bin\");\n        if ws_bin.as_path().is_dir() {\n            dirs.push(ws_bin.as_path().to_path_buf());\n        }\n        dirs.extend(std::env::split_paths(&std::env::var_os(\"PATH\").unwrap_or_default()));\n        dirs\n    };\n    let base_path = std::env::join_paths(&base_path_dirs).unwrap_or_default();\n\n    let cmd_display = args.command.join(\" \");\n\n    // Track per-package results for --report-summary\n    let mut summary: BTreeMap<String, serde_json::Value> = BTreeMap::new();\n\n    let exit_status = if args.parallel && !single_package {\n        // Parallel: spawn all processes with independent timing via tokio::spawn\n        let mut handles: Vec<(\n            String,\n            tokio::task::JoinHandle<\n                Result<(std::process::Output, std::time::Duration), std::io::Error>,\n            >,\n        )> = Vec::new();\n        for &idx in &selected {\n            let pkg = &package_graph[idx];\n            let pkg_name = pkg.package_json.name.to_string();\n            let pkg_path = &pkg.absolute_path;\n\n            let path_env = build_package_path_env(pkg_path, &base_path_dirs, &base_path);\n            let exec_dir: &vite_path::AbsolutePath =\n                if use_caller_cwd { cwd.as_ref() } else { pkg_path };\n            let mut cmd = build_exec_command(\n                args.shell_mode,\n                &args.command,\n                &cmd_display,\n                &path_env,\n                exec_dir,\n            )?;\n            cmd.env(\"PATH\", &path_env)\n                .env(\"VITE_PLUS_PACKAGE_NAME\", &pkg_name)\n                .stdout(Stdio::piped())\n                .stderr(Stdio::piped());\n\n            let start = std::time::Instant::now();\n            let child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;\n            let handle = tokio::spawn(async move {\n                let output = child.wait_with_output().await?;\n                let duration = start.elapsed();\n                Ok((output, duration))\n            });\n            handles.push((pkg_name, handle));\n        }\n\n        // Collect results in order for deterministic output\n        let mut results = Vec::new();\n        for (name, handle) in handles {\n            let (output, duration) = handle\n                .await\n                .map_err(|e| Error::Anyhow(e.into()))?\n                .map_err(|e| Error::Anyhow(e.into()))?;\n            results.push((name, output, duration));\n        }\n\n        // Print outputs in order and track worst exit code\n        let mut worst_exit = 0u8;\n        for (name, output, duration) in &results {\n            if show_prefix {\n                vite_shared::output::raw(&vite_str::format!(\"{name}$ {cmd_display}\"));\n            }\n            use std::io::Write;\n            let _ = std::io::stdout().write_all(&output.stdout);\n            let _ = std::io::stderr().write_all(&output.stderr);\n            let code = output.status.code().unwrap_or(1) as u8;\n            if code > worst_exit {\n                worst_exit = code;\n            }\n            if args.report_summary {\n                let status = if code == 0 { \"passed\" } else { \"failed\" };\n                summary.insert(\n                    name.clone(),\n                    serde_json::json!({\n                        \"status\": status,\n                        \"duration\": duration.as_secs_f64() * 1000.0,\n                    }),\n                );\n            }\n        }\n\n        ExitStatus(worst_exit)\n    } else {\n        // Sequential execution\n        let mut final_status = ExitStatus::SUCCESS;\n        for &idx in &selected {\n            let pkg = &package_graph[idx];\n            let pkg_name = pkg.package_json.name.as_str();\n            let pkg_path = &pkg.absolute_path;\n\n            let path_env = build_package_path_env(pkg_path, &base_path_dirs, &base_path);\n\n            if show_prefix {\n                vite_shared::output::raw(&vite_str::format!(\"{pkg_name}$ {cmd_display}\"));\n            }\n\n            let start = std::time::Instant::now();\n\n            let exec_dir: &vite_path::AbsolutePath =\n                if use_caller_cwd { cwd.as_ref() } else { pkg_path };\n            let mut cmd = match build_exec_command(\n                args.shell_mode,\n                &args.command,\n                &cmd_display,\n                &path_env,\n                exec_dir,\n            ) {\n                Ok(cmd) => cmd,\n                Err(Error::CannotFindBinaryPath(_)) if single_package => {\n                    let command = args.command[0].bright_blue().to_string();\n                    let vp_install = \"`vp install`\".bright_blue().to_string();\n                    let vpx = \"`vpx`\".bright_blue().to_string();\n                    vite_shared::output::error(&vite_str::format!(\n                        \"Command '{}' not found in node_modules/.bin\\n\\n\\\n                         Run {} to install dependencies, or use {} for invoking remote commands.\",\n                        command,\n                        vp_install,\n                        vpx\n                    ));\n                    return Ok(ExitStatus(1));\n                }\n                Err(e) => return Err(e),\n            };\n            cmd.env(\"PATH\", &path_env).env(\"VITE_PLUS_PACKAGE_NAME\", pkg_name);\n\n            let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;\n            let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;\n            let duration = start.elapsed();\n            let code = status.code().unwrap_or(1) as u8;\n\n            if args.report_summary {\n                let pkg_status = if code == 0 { \"passed\" } else { \"failed\" };\n                summary.insert(\n                    pkg_name.to_string(),\n                    serde_json::json!({\n                        \"status\": pkg_status,\n                        \"duration\": duration.as_secs_f64() * 1000.0,\n                    }),\n                );\n            }\n\n            if code != 0 {\n                final_status = ExitStatus(code);\n                break;\n            }\n        }\n\n        final_status\n    };\n\n    // Write report summary if requested\n    if args.report_summary {\n        let report = serde_json::json!({ \"executionStatus\": summary });\n        let report_path = cwd.join(\"vp-exec-summary.json\");\n        if let Err(e) =\n            std::fs::write(report_path.as_path(), serde_json::to_string_pretty(&report).unwrap())\n        {\n            vite_shared::output::error(&vite_str::format!(\n                \"Failed to write vp-exec-summary.json: {}\",\n                e\n            ));\n        }\n    }\n\n    Ok(exit_status)\n}\n\n/// Build a PATH value for a package, prepending its local node_modules/.bin.\nfn build_package_path_env(\n    pkg_path: &vite_path::AbsolutePath,\n    base_path_dirs: &[std::path::PathBuf],\n    base_path: &std::ffi::OsStr,\n) -> std::ffi::OsString {\n    let bin_dir = pkg_path.join(\"node_modules\").join(\".bin\");\n    if bin_dir.as_path().is_dir() {\n        std::env::join_paths(\n            std::iter::once(bin_dir.as_path().to_path_buf()).chain(base_path_dirs.iter().cloned()),\n        )\n        .unwrap_or_default()\n    } else {\n        base_path.to_os_string()\n    }\n}\n\n/// Build a [`tokio::process::Command`] for the exec invocation in a package directory.\nfn build_exec_command(\n    shell_mode: bool,\n    command: &[String],\n    cmd_display: &str,\n    path_env: &std::ffi::OsStr,\n    pkg_path: &vite_path::AbsolutePath,\n) -> Result<tokio::process::Command, Error> {\n    if shell_mode {\n        Ok(vite_command::build_shell_command(cmd_display, pkg_path))\n    } else {\n        let bin_path = vite_command::resolve_bin(&command[0], Some(path_env), pkg_path)?;\n        let mut cmd = vite_command::build_command(&bin_path, pkg_path);\n        if command.len() > 1 {\n            cmd.args(&command[1..]);\n        }\n        Ok(cmd)\n    }\n}\n\n/// Sort package indices in topological order (dependencies before dependents).\n///\n/// Uses `petgraph::algo::toposort` for the common acyclic case.\n/// When cycles exist, falls back to `petgraph::algo::tarjan_scc` which\n/// returns SCCs in reverse topological order — preserving correct ordering\n/// for non-cyclic dependencies even when cycles are present.\nfn topological_sort_packages(subgraph: &DiGraphMap<PackageNodeIndex, ()>) -> Vec<PackageNodeIndex> {\n    match petgraph::algo::toposort(subgraph, None) {\n        Ok(mut sorted) => {\n            sorted.reverse();\n            sorted\n        }\n        Err(_cycle) => {\n            // tarjan_scc returns SCCs in reverse topological order of the\n            // condensed DAG.  Edges are dependent → dependency, so reverse\n            // topological = dependencies first — exactly the order we want.\n            // Within a cycle SCC, no valid linear ordering exists; the\n            // intra-SCC order is arbitrary (and correct).\n            petgraph::algo::tarjan_scc(subgraph).into_iter().flatten().collect()\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use std::path::PathBuf;\n\n    use petgraph::prelude::DiGraphMap;\n    use rustc_hash::FxHashSet;\n    use vite_path::{AbsolutePathBuf, RelativePathBuf};\n    use vite_workspace::{DependencyType, PackageInfo, PackageJson, PackageNodeIndex};\n\n    use super::*;\n\n    /// Create a cross-platform absolute path for tests.\n    /// On Unix `/workspace/...`, on Windows `C:\\workspace\\...`.\n    fn test_absolute_path(suffix: &str) -> Arc<vite_path::AbsolutePath> {\n        #[cfg(windows)]\n        let base = PathBuf::from(format!(\"C:\\\\workspace{}\", suffix.replace('/', \"\\\\\")));\n        #[cfg(not(windows))]\n        let base = PathBuf::from(format!(\"/workspace{suffix}\"));\n        AbsolutePathBuf::new(base).unwrap().into()\n    }\n\n    /// Build a test dependency graph:\n    /// - app-a depends on lib-c\n    /// - app-b has no workspace dependencies\n    /// - lib-c has no workspace dependencies\n    /// - root (workspace root, empty path)\n    fn build_test_graph()\n    -> petgraph::graph::DiGraph<PackageInfo, DependencyType, vite_workspace::PackageIx> {\n        let mut graph = petgraph::graph::DiGraph::default();\n\n        let root = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"root\".into(), ..Default::default() },\n            path: RelativePathBuf::default(),\n            absolute_path: test_absolute_path(\"\"),\n        });\n        let app_a = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"app-a\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/app-a\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/app-a\"),\n        });\n        let app_b = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"app-b\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/app-b\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/app-b\"),\n        });\n        let lib_c = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"lib-c\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/lib-c\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/lib-c\"),\n        });\n\n        // app-a depends on lib-c\n        graph.add_edge(app_a, lib_c, DependencyType::Normal);\n\n        let _ = (root, app_b); // suppress unused warnings\n        graph\n    }\n\n    /// Build a DiGraphMap subgraph from selected node indices and the original graph edges.\n    fn build_subgraph(\n        graph: &petgraph::graph::DiGraph<PackageInfo, DependencyType, vite_workspace::PackageIx>,\n        selected: &[PackageNodeIndex],\n    ) -> DiGraphMap<PackageNodeIndex, ()> {\n        use petgraph::visit::EdgeRef;\n        let selected_set: FxHashSet<PackageNodeIndex> = selected.iter().copied().collect();\n        let mut subgraph = DiGraphMap::new();\n        for &idx in selected {\n            subgraph.add_node(idx);\n        }\n        for edge in graph.edge_references() {\n            let src = edge.source();\n            let dst = edge.target();\n            if selected_set.contains(&src) && selected_set.contains(&dst) {\n                subgraph.add_edge(src, dst, ());\n            }\n        }\n        subgraph\n    }\n\n    #[test]\n    fn test_topological_sort_simple() {\n        let graph = build_test_graph();\n        // All non-root packages\n        let all: Vec<_> =\n            graph.node_indices().filter(|&idx| !graph[idx].path.as_str().is_empty()).collect();\n        let subgraph = build_subgraph(&graph, &all);\n        let sorted = topological_sort_packages(&subgraph);\n        let names: Vec<&str> =\n            sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect();\n        // lib-c must precede app-a (dependency)\n        let lib_c_pos = names.iter().position(|&n| n == \"lib-c\").unwrap();\n        let app_a_pos = names.iter().position(|&n| n == \"app-a\").unwrap();\n        assert!(lib_c_pos < app_a_pos);\n        assert_eq!(names.len(), 3);\n    }\n\n    #[test]\n    fn test_topological_sort_with_cycles() {\n        let mut graph = petgraph::graph::DiGraph::default();\n\n        let root = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"root\".into(), ..Default::default() },\n            path: RelativePathBuf::default(),\n            absolute_path: test_absolute_path(\"\"),\n        });\n        let pkg_a = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"pkg-a\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/pkg-a\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/pkg-a\"),\n        });\n        let pkg_b = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"pkg-b\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/pkg-b\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/pkg-b\"),\n        });\n        let pkg_c = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"pkg-c\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/pkg-c\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/pkg-c\"),\n        });\n\n        // Circular: pkg-a <-> pkg-b\n        graph.add_edge(pkg_a, pkg_b, DependencyType::Normal);\n        graph.add_edge(pkg_b, pkg_a, DependencyType::Normal);\n        // pkg-c has no dependencies\n        let _ = root;\n\n        let selected = vec![pkg_a, pkg_b, pkg_c];\n        let subgraph = build_subgraph(&graph, &selected);\n        let sorted = topological_sort_packages(&subgraph);\n        let names: Vec<&str> =\n            sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect();\n        // All three packages present; pkg-a/pkg-b are cyclic so no ordering\n        // constraint exists between them or relative to independent pkg-c.\n        assert_eq!(names.len(), 3);\n        assert!(names.contains(&\"pkg-a\"));\n        assert!(names.contains(&\"pkg-b\"));\n        assert!(names.contains(&\"pkg-c\"));\n    }\n\n    #[test]\n    fn test_topological_sort_cycle_with_dependent() {\n        let mut graph = petgraph::graph::DiGraph::default();\n\n        let _root = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"root\".into(), ..Default::default() },\n            path: RelativePathBuf::default(),\n            absolute_path: test_absolute_path(\"\"),\n        });\n        let a = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"a\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/a\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/a\"),\n        });\n        let b = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"b\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/b\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/b\"),\n        });\n        let aa = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"aa\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/aa\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/aa\"),\n        });\n\n        // Cycle: a <-> b\n        graph.add_edge(a, b, DependencyType::Normal);\n        graph.add_edge(b, a, DependencyType::Normal);\n        // aa depends on b (non-cyclic dependent)\n        graph.add_edge(aa, b, DependencyType::Normal);\n\n        let selected = vec![a, b, aa];\n        let subgraph = build_subgraph(&graph, &selected);\n        let sorted = topological_sort_packages(&subgraph);\n        let names: Vec<&str> =\n            sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect();\n        // b must come before aa (aa depends on b). a and b are cyclic so\n        // their relative order is unspecified.\n        let b_pos = names.iter().position(|&n| n == \"b\").unwrap();\n        let aa_pos = names.iter().position(|&n| n == \"aa\").unwrap();\n        assert!(b_pos < aa_pos);\n        assert_eq!(names.len(), 3);\n    }\n\n    #[test]\n    fn test_topological_sort_cycle_with_non_cyclic_dependency() {\n        let mut graph = petgraph::graph::DiGraph::default();\n\n        let _root = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"root\".into(), ..Default::default() },\n            path: RelativePathBuf::default(),\n            absolute_path: test_absolute_path(\"\"),\n        });\n        // Add c FIRST so it gets a lower node index than a/b.\n        // This matters because tarjan_scc's intra-SCC order can depend on\n        // graph internals; placing c early verifies that the SCC boundary\n        // (not insertion order) determines the final position.\n        let c = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"c\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/c\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/c\"),\n        });\n        let a = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"a\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/a\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/a\"),\n        });\n        let b = graph.add_node(PackageInfo {\n            package_json: PackageJson { name: \"b\".into(), ..Default::default() },\n            path: RelativePathBuf::try_from(\"packages/b\").unwrap(),\n            absolute_path: test_absolute_path(\"/packages/b\"),\n        });\n\n        // Cycle: a <-> b\n        graph.add_edge(a, b, DependencyType::Normal);\n        graph.add_edge(b, a, DependencyType::Normal);\n        // a depends on c (non-cyclic dependency)\n        graph.add_edge(a, c, DependencyType::Normal);\n\n        // Insert c first so it gets the earliest position in the subgraph's\n        // internal IndexMap, ensuring the test is not accidentally passing\n        // due to favorable insertion order.\n        let selected = vec![c, a, b];\n        let subgraph = build_subgraph(&graph, &selected);\n        let sorted = topological_sort_packages(&subgraph);\n        let names: Vec<&str> =\n            sorted.iter().map(|&idx| graph[idx].package_json.name.as_str()).collect();\n        // c must come before a (a depends on c). a and b are cyclic so\n        // their relative order is unspecified, but c must precede both\n        // since it is a dependency of a.\n        let c_pos = names.iter().position(|&n| n == \"c\").unwrap();\n        let a_pos = names.iter().position(|&n| n == \"a\").unwrap();\n        assert!(c_pos < a_pos, \"c ({c_pos}) should precede a ({a_pos}), got: {names:?}\");\n        assert_eq!(names.len(), 3);\n    }\n}\n"
  },
  {
    "path": "packages/cli/binding/src/lib.rs",
    "content": "//! NAPI binding layer for vite-plus CLI\n//!\n//! This module provides the bridge between JavaScript tool resolvers and the Rust core.\n//! It uses NAPI-RS to create native Node.js bindings that allow JavaScript functions\n//! to be called from Rust code.\n\n#[cfg(feature = \"rolldown\")]\npub extern crate rolldown_binding;\n\nmod cli;\nmod exec;\n// These modules export NAPI functions only called from JavaScript at runtime.\n// allow(dead_code) suppresses warnings in the test target which doesn't link NAPI.\n#[allow(dead_code)]\nmod migration;\n#[allow(dead_code)]\nmod package_manager;\n#[allow(dead_code)]\nmod utils;\n\nuse std::{collections::HashMap, error::Error as StdError, ffi::OsStr, fmt::Write as _, sync::Arc};\n\nuse napi::{anyhow, bindgen_prelude::*, threadsafe_function::ThreadsafeFunction};\nuse napi_derive::napi;\nuse vite_path::current_dir;\n\nuse crate::cli::{\n    BoxedResolverFn, CliOptions as ViteTaskCliOptions, ResolveCommandResult, ViteConfigResolverFn,\n};\n\n/// Module initialization - sets up tracing for debugging\n#[napi_derive::module_init]\npub fn init() {\n    crate::cli::init_tracing();\n}\n\n/// Configuration options passed from JavaScript to Rust.\n#[napi(object, object_to_js = false)]\npub struct CliOptions {\n    pub lint: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    pub fmt: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    pub vite: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    pub test: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    pub pack: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    pub doc: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    pub cwd: Option<String>,\n    /// CLI arguments (should be process.argv.slice(2) from JavaScript)\n    pub args: Option<Vec<String>>,\n    /// Read the vite.config.ts in the Node.js side and return the `lint` and `fmt` config JSON string back to the Rust side\n    pub resolve_universal_vite_config: Arc<ThreadsafeFunction<String, Promise<String>>>,\n}\n\n/// Result returned by JavaScript resolver functions.\n#[napi(object, object_to_js = false)]\npub struct JsCommandResolvedResult {\n    pub bin_path: String,\n    pub envs: HashMap<String, String>,\n}\n\nimpl From<JsCommandResolvedResult> for ResolveCommandResult {\n    fn from(value: JsCommandResolvedResult) -> Self {\n        Self {\n            bin_path: Arc::<OsStr>::from(OsStr::new(&value.bin_path).to_os_string()),\n            envs: value.envs.into_iter().collect(),\n        }\n    }\n}\n\n/// Create a boxed resolver function from a ThreadsafeFunction\n/// NOTE: Uses anyhow::Error to avoid NAPI type interference with vite_error::Error\nfn create_resolver(\n    tsf: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,\n    error_message: &'static str,\n) -> BoxedResolverFn {\n    Box::new(move || {\n        let tsf = tsf.clone();\n        Box::pin(async move {\n            // Call JS function - map napi::Error to anyhow::Error\n            let promise: Promise<JsCommandResolvedResult> = tsf\n                .call_async(Ok(()))\n                .await\n                .map_err(|e| anyhow::anyhow!(\"{}: {}\", error_message, e))?;\n\n            // Await the promise\n            let resolved: JsCommandResolvedResult =\n                promise.await.map_err(|e| anyhow::anyhow!(\"{}: {}\", error_message, e))?;\n\n            Ok(resolved.into())\n        })\n    })\n}\n\n/// Create an Arc-wrapped vite config resolver function from a ThreadsafeFunction\nfn create_vite_config_resolver(\n    tsf: Arc<ThreadsafeFunction<String, Promise<String>>>,\n) -> ViteConfigResolverFn {\n    Arc::new(move |package_path: String| {\n        let tsf = tsf.clone();\n        Box::pin(async move {\n            let promise: Promise<String> = tsf\n                .call_async(Ok(package_path))\n                .await\n                .map_err(|e| anyhow::anyhow!(\"Failed to resolve vite config: {}\", e))?;\n\n            let resolved: String = promise\n                .await\n                .map_err(|e| anyhow::anyhow!(\"Failed to resolve vite config: {}\", e))?;\n\n            Ok(resolved)\n        })\n    })\n}\n\nfn format_error_message(error: &(dyn StdError + 'static)) -> String {\n    let mut message = error.to_string();\n    let mut source = error.source();\n\n    while let Some(current) = source {\n        let _ = write!(message, \"\\n* {current}\");\n        source = current.source();\n    }\n\n    message\n}\n\n/// Main entry point for the CLI, called from JavaScript.\n///\n/// This is an async function that spawns a new thread for the non-Send async code\n/// from vite_task, while allowing the NAPI async context to continue running\n/// and process JavaScript callbacks (via ThreadsafeFunction).\n#[napi]\npub async fn run(options: CliOptions) -> Result<i32> {\n    // Use provided cwd or current directory\n    let mut cwd = current_dir()?;\n    if let Some(options_cwd) = options.cwd {\n        cwd.push(options_cwd);\n    }\n\n    // Extract ThreadsafeFunctions (which are Send+Sync) to move to the worker thread\n    let lint_tsf = options.lint;\n    let fmt_tsf = options.fmt;\n    let vite_tsf = options.vite;\n    let test_tsf = options.test;\n    let pack_tsf = options.pack;\n    let doc_tsf = options.doc;\n    let resolve_universal_vite_config_tsf = options.resolve_universal_vite_config;\n    let args = options.args;\n\n    // Create a channel to receive the result from the worker thread\n    let (tx, rx) = tokio::sync::oneshot::channel();\n\n    // Spawn a new thread for the non-Send async code\n    // ThreadsafeFunction is designed to work across threads, so the resolver\n    // callbacks will still be able to call back to JavaScript\n    std::thread::spawn(move || {\n        // Create the resolvers inside the thread (BoxedResolverFn is not Send)\n        let cli_options = ViteTaskCliOptions {\n            lint: create_resolver(lint_tsf, \"Failed to resolve lint command\"),\n            fmt: create_resolver(fmt_tsf, \"Failed to resolve fmt command\"),\n            vite: create_resolver(vite_tsf, \"Failed to resolve vite command\"),\n            test: create_resolver(test_tsf, \"Failed to resolve test command\"),\n            pack: create_resolver(pack_tsf, \"Failed to resolve pack command\"),\n            doc: create_resolver(doc_tsf, \"Failed to resolve doc command\"),\n            resolve_universal_vite_config: create_vite_config_resolver(\n                resolve_universal_vite_config_tsf,\n            ),\n        };\n\n        // Create a new single-threaded runtime for non-Send futures\n        let rt = tokio::runtime::Builder::new_current_thread()\n            .enable_all()\n            .build()\n            .expect(\"Failed to create runtime\");\n\n        // Run the CLI in a LocalSet to allow non-Send futures\n        let local = tokio::task::LocalSet::new();\n        let result =\n            local.block_on(&rt, async { crate::cli::main(cwd, Some(cli_options), args).await });\n\n        // Send the result back to the NAPI async context\n        let _ = tx.send(result);\n    });\n\n    // Wait for the result from the worker thread\n    let result = rx.await.map_err(|_| napi::Error::from_reason(\"Worker thread panicked\"))?;\n\n    tracing::debug!(\"Result: {result:?}\");\n\n    match result {\n        Ok(exit_status) => Ok(exit_status.0.into()),\n        Err(e) => match e {\n            vite_error::Error::UserCancelled => Ok(130),\n            _ => {\n                tracing::error!(\"Rust error: {:?}\", e);\n                Err(napi::Error::from_reason(format_error_message(&e)))\n            }\n        },\n    }\n}\n\n/// Render the Vite+ header using the Rust implementation.\n#[napi]\npub fn vite_plus_header() -> String {\n    vite_shared::header::vite_plus_header()\n}\n"
  },
  {
    "path": "packages/cli/binding/src/migration.rs",
    "content": "use std::path::Path;\n\nuse napi::{anyhow, bindgen_prelude::*};\nuse napi_derive::napi;\n\n/// Rewrite scripts json content using rules from rules_yaml\n///\n/// # Arguments\n///\n/// * `scripts_json` - The scripts section of the package.json file as a JSON string\n/// * `rules_yaml` - The ast-grep rules.yaml as a YAML string\n///\n/// # Returns\n///\n/// * `updated` - The updated scripts section of the package.json file as a JSON string, or `null` if no updates were made\n///\n/// # Example\n///\n/// ```javascript\n/// const updated = rewriteScripts(\"scripts section json content here\", \"ast-grep rules yaml content here\");\n/// console.log(`Updated: ${updated}`);\n/// ```\n#[napi]\npub fn rewrite_scripts(scripts_json: String, rules_yaml: String) -> Result<Option<String>> {\n    let updated =\n        vite_migration::rewrite_scripts(&scripts_json, &rules_yaml).map_err(anyhow::Error::from)?;\n    Ok(updated)\n}\n\n/// Rewrite ESLint scripts: rename `eslint` → `vp lint` and strip ESLint-only flags.\n///\n/// Uses brush-parser to parse shell commands, so it correctly handles env var prefixes,\n/// compound commands (`&&`, `||`, `|`), and quoted arguments.\n///\n/// # Arguments\n///\n/// * `scripts_json` - The scripts section as a JSON string\n///\n/// # Returns\n///\n/// * `updated` - The updated scripts JSON string, or `null` if no changes were made\n#[napi]\npub fn rewrite_eslint(scripts_json: String) -> Result<Option<String>> {\n    let updated = vite_migration::rewrite_eslint(&scripts_json).map_err(anyhow::Error::from)?;\n    Ok(updated)\n}\n\n/// Rewrite Prettier scripts: rename `prettier` → `vp fmt` and strip Prettier-only flags.\n///\n/// Uses brush-parser to parse shell commands, so it correctly handles env var prefixes,\n/// compound commands (`&&`, `||`, `|`), and quoted arguments.\n///\n/// # Arguments\n///\n/// * `scripts_json` - The scripts section as a JSON string\n///\n/// # Returns\n///\n/// * `updated` - The updated scripts JSON string, or `null` if no changes were made\n#[napi]\npub fn rewrite_prettier(scripts_json: String) -> Result<Option<String>> {\n    let updated = vite_migration::rewrite_prettier(&scripts_json).map_err(anyhow::Error::from)?;\n    Ok(updated)\n}\n\n/// Result of merging JSON config into vite config\n#[napi(object)]\npub struct MergeJsonConfigResult {\n    /// The updated vite config content\n    pub content: String,\n    /// Whether any changes were made\n    pub updated: bool,\n    /// Whether the config uses a function callback\n    pub uses_function_callback: bool,\n}\n\n/// Merge JSON configuration file into vite config file\n///\n/// This function reads the files from disk and merges the JSON config\n/// into the vite configuration file.\n///\n/// # Arguments\n///\n/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file\n/// * `json_config_path` - Path to the JSON config file (e.g., .oxlintrc, .oxfmtrc)\n/// * `config_key` - The key to use in the vite config (e.g., \"lint\", \"fmt\")\n///\n/// # Returns\n///\n/// Returns a `MergeJsonConfigResult` containing:\n/// - `content`: The updated vite config content\n/// - `updated`: Whether any changes were made\n/// - `usesFunctionCallback`: Whether the config uses a function callback\n///\n/// # Example\n///\n/// ```javascript\n/// const result = mergeJsonConfig('vite.config.ts', '.oxlintrc', 'lint');\n/// if (result.updated) {\n///     fs.writeFileSync('vite.config.ts', result.content);\n/// }\n/// ```\n#[napi]\npub fn merge_json_config(\n    vite_config_path: String,\n    json_config_path: String,\n    config_key: String,\n) -> Result<MergeJsonConfigResult> {\n    let result = vite_migration::merge_json_config(\n        Path::new(&vite_config_path),\n        Path::new(&json_config_path),\n        &config_key,\n    )\n    .map_err(anyhow::Error::from)?;\n\n    Ok(MergeJsonConfigResult {\n        content: result.content,\n        updated: result.updated,\n        uses_function_callback: result.uses_function_callback,\n    })\n}\n\n/// Error from batch import rewriting\n#[napi(object)]\npub struct BatchRewriteError {\n    /// The file path that had an error\n    pub path: String,\n    /// The error message\n    pub message: String,\n}\n\n/// Result of rewriting imports in multiple files\n#[napi(object)]\npub struct BatchRewriteResult {\n    /// Files that were modified\n    pub modified_files: Vec<String>,\n    /// Files that had errors\n    pub errors: Vec<BatchRewriteError>,\n}\n\n/// Merge tsdown config into vite config by importing it\n///\n/// This function adds an import statement for the tsdown config file\n/// and adds `pack: packConfig` to the defineConfig.\n///\n/// # Arguments\n///\n/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file\n/// * `tsdown_config_path` - Relative path to the tsdown.config.ts file (e.g., \"./tsdown.config.ts\")\n///\n/// # Returns\n///\n/// Returns a `MergeJsonConfigResult` containing:\n/// - `content`: The updated vite config content\n/// - `updated`: Whether any changes were made\n/// - `usesFunctionCallback`: Whether the config uses a function callback\n///\n/// # Example\n///\n/// ```javascript\n/// const result = mergeTsdownConfig('vite.config.ts', './tsdown.config.ts');\n/// if (result.updated) {\n///     fs.writeFileSync('vite.config.ts', result.content);\n/// }\n/// ```\n#[napi]\npub fn merge_tsdown_config(\n    vite_config_path: String,\n    tsdown_config_path: String,\n) -> Result<MergeJsonConfigResult> {\n    let result =\n        vite_migration::merge_tsdown_config(Path::new(&vite_config_path), &tsdown_config_path)\n            .map_err(anyhow::Error::from)?;\n\n    Ok(MergeJsonConfigResult {\n        content: result.content,\n        updated: result.updated,\n        uses_function_callback: result.uses_function_callback,\n    })\n}\n\n/// Rewrite imports in all TypeScript/JavaScript files under a directory\n///\n/// This function finds all TypeScript and JavaScript files in the specified directory\n/// (respecting `.gitignore` rules), applies the import rewrite rules to each file,\n/// and writes the modified content back to disk.\n///\n/// # Arguments\n///\n/// * `root` - The root directory to search for files\n///\n/// # Returns\n///\n/// Returns a `BatchRewriteResult` containing:\n/// - `modifiedFiles`: Files that were changed\n/// - `errors`: Files that had errors during processing\n///\n/// # Example\n///\n/// ```javascript\n/// const result = rewriteImportsInDirectory('./src');\n/// console.log(`Modified ${result.modifiedFiles.length} files`);\n/// for (const file of result.modifiedFiles) {\n///     console.log(`  ${file}`);\n/// }\n/// ```\n#[napi]\npub fn rewrite_imports_in_directory(root: String) -> Result<BatchRewriteResult> {\n    let result = vite_migration::rewrite_imports_in_directory(Path::new(&root))\n        .map_err(anyhow::Error::from)?;\n\n    Ok(BatchRewriteResult {\n        modified_files: result\n            .modified_files\n            .iter()\n            .map(|p| p.to_string_lossy().to_string())\n            .collect(),\n        errors: result\n            .errors\n            .iter()\n            .map(|(p, m)| BatchRewriteError {\n                path: p.to_string_lossy().to_string(),\n                message: m.clone(),\n            })\n            .collect(),\n    })\n}\n"
  },
  {
    "path": "packages/cli/binding/src/package_manager.rs",
    "content": "use napi::{Error, anyhow, bindgen_prelude::*};\nuse napi_derive::napi;\nuse vite_error::Error::{UnrecognizedPackageManager, UnsupportedPackageManager};\nuse vite_install::{PackageManagerType, get_package_manager_type_and_version};\nuse vite_path::AbsolutePathBuf;\nuse vite_workspace::{Error::PackageJsonNotFound, WorkspaceFile, find_workspace_root};\n\n#[napi(object)]\n#[derive(Debug)]\npub struct DownloadPackageManagerOptions {\n    pub name: String,\n    pub version: String,\n    pub expected_hash: Option<String>,\n}\n\n#[napi(object)]\n#[derive(Debug)]\npub struct DownloadPackageManagerResult {\n    pub name: String,\n    pub install_dir: String,\n    pub bin_prefix: String,\n    pub package_name: String,\n    pub version: String,\n}\n\n/// Download a package manager\n///\n/// ## Parameters\n///\n/// - `options`: Configuration for the package manager to download, including:\n///   - `name`: The name of the package manager\n///   - `version`: The version of the package manager\n///   - `expected_hash`: The expected hash of the package manager\n///\n/// ## Returns\n///\n/// Returns a `DownloadPackageManagerResult` containing:\n/// - The name of the package manager\n/// - The install directory of the package manager\n/// - The binary prefix of the package manager\n/// - The package name of the package manager\n/// - The version of the package manager\n///\n/// ## Example\n///\n/// ```javascript\n/// const result = await downloadPackageManager({\n///   name: \"pnpm\",\n///   version: \"latest\",\n/// });\n/// console.log(`Package manager name: ${result.name}`);\n/// console.log(`Package manager install directory: ${result.installDir}`);\n/// console.log(`Package manager binary prefix: ${result.binPrefix}`);\n/// console.log(`Package manager package name: ${result.packageName}`);\n/// console.log(`Package manager version: ${result.version}`);\n/// ```\n#[napi]\npub async fn download_package_manager(\n    options: DownloadPackageManagerOptions,\n) -> Result<DownloadPackageManagerResult> {\n    let package_manager_type = match options.name.as_str() {\n        \"pnpm\" => PackageManagerType::Pnpm,\n        \"yarn\" => PackageManagerType::Yarn,\n        \"npm\" => PackageManagerType::Npm,\n        _ => {\n            return Err(Error::from_reason(format!(\n                \"Invalid package manager name: {}\",\n                options.name\n            )));\n        }\n    };\n\n    let (install_dir, package_name, version) = vite_install::download_package_manager(\n        package_manager_type,\n        &options.version,\n        options.expected_hash.as_deref(),\n    )\n    .await\n    .map_err(anyhow::Error::from)?;\n\n    Ok(DownloadPackageManagerResult {\n        name: options.name,\n        install_dir: install_dir.as_path().to_string_lossy().to_string(),\n        bin_prefix: install_dir.join(\"bin\").as_path().to_string_lossy().to_string(),\n        package_name: package_name.to_string(),\n        version: version.to_string(),\n    })\n}\n\n#[napi(object)]\n#[derive(Debug)]\npub struct DetectWorkspaceResult {\n    pub package_manager_name: Option<String>,\n    pub package_manager_version: Option<String>,\n    pub is_monorepo: bool,\n    pub root: Option<String>,\n}\n\n/// Detect the workspace root and package manager type and version\n///\n/// ## Parameters\n///\n/// - `cwd`: The current working directory to detect the workspace root\n///\n/// ## Returns\n///\n/// Returns a `DetectWorkspaceResult` containing:\n/// - The name of the package manager\n/// - The version of the package manager\n/// - Whether the workspace is a monorepo\n/// - The workspace root, where the package.json file is located.\n///\n/// ## Example\n///\n/// ```javascript\n/// const result = await detectWorkspace(\"/path/to/workspace\");\n/// console.log(`Package manager name: ${result.packageManagerName}`);\n/// console.log(`Package manager version: ${result.packageManagerVersion}`);\n/// console.log(`Is monorepo: ${result.isMonorepo}`);\n/// console.log(`Workspace root: ${result.root}`);\n/// ```\n#[napi]\npub async fn detect_workspace(cwd: String) -> Result<DetectWorkspaceResult> {\n    let cwd = AbsolutePathBuf::new(cwd.into()).ok_or(Error::from_reason(\"invalid cwd\"))?;\n    let (workspace_root, _relative_path) = match find_workspace_root(&cwd) {\n        Ok(result) => result,\n        Err(PackageJsonNotFound(_)) => {\n            return Ok(DetectWorkspaceResult {\n                package_manager_name: None,\n                package_manager_version: None,\n                is_monorepo: false,\n                root: None,\n            });\n        }\n        Err(e) => {\n            return Err(anyhow::Error::from(e).into());\n        }\n    };\n\n    let is_monorepo = matches!(\n        workspace_root.workspace_file,\n        WorkspaceFile::PnpmWorkspaceYaml(_) | WorkspaceFile::NpmWorkspaceJson(_)\n    );\n    let workspace_root_path = workspace_root.path.as_path().to_string_lossy().to_string();\n\n    match get_package_manager_type_and_version(&workspace_root, None) {\n        Ok((package_manager_type, version, _)) => Ok(DetectWorkspaceResult {\n            package_manager_name: Some(package_manager_type.to_string()),\n            package_manager_version: Some(version.to_string()),\n            is_monorepo,\n            root: Some(workspace_root_path),\n        }),\n        Err(UnsupportedPackageManager(_) | UnrecognizedPackageManager) => {\n            Ok(DetectWorkspaceResult {\n                package_manager_name: None,\n                package_manager_version: None,\n                is_monorepo,\n                root: Some(workspace_root_path),\n            })\n        }\n        Err(e) => {\n            return Err(anyhow::Error::from(e).into());\n        }\n    }\n}\n"
  },
  {
    "path": "packages/cli/binding/src/utils.rs",
    "content": "use std::{collections::HashMap, path::PathBuf};\n\nuse fspy::AccessMode;\nuse napi::{anyhow, bindgen_prelude::*};\nuse napi_derive::napi;\nuse vite_command::run_command_with_fspy;\nuse vite_path::AbsolutePathBuf;\n\n/// Input parameters for running a command with fspy tracking.\n///\n/// This structure contains the information needed to execute a command:\n/// - `bin_name`: The name of the binary to execute\n/// - `args`: Command line arguments to pass to the binary\n/// - `envs`: Environment variables to set when executing the command\n/// - `cwd`: The current working directory for the command\n#[napi(object, object_to_js = false)]\n#[derive(Debug)]\npub struct RunCommandOptions {\n    /// The name of the binary to execute\n    pub bin_name: String,\n    /// Command line arguments to pass to the binary\n    pub args: Vec<String>,\n    /// Environment variables to set when executing the command\n    pub envs: HashMap<String, String>,\n    /// The current working directory for the command\n    pub cwd: String,\n}\n\n/// Access modes for a path.\n#[napi(object)]\n#[derive(Debug)]\npub struct PathAccess {\n    /// Whether the path was read\n    pub read: bool,\n    /// Whether the path was written\n    pub write: bool,\n    /// Whether the path was read as a directory\n    pub read_dir: bool,\n}\n\n/// Result returned by the run_command function.\n///\n/// This structure contains:\n/// - `exit_code`: The exit code of the command\n/// - `path_accesses`: A map of relative paths to their access modes\n#[napi(object)]\n#[derive(Debug)]\npub struct RunCommandResult {\n    /// The exit code of the command\n    pub exit_code: i32,\n    /// Map of relative paths to their access modes\n    pub path_accesses: HashMap<String, PathAccess>,\n}\n\n/// Run a command with fspy tracking, callable from JavaScript.\n///\n/// This function wraps `vite_command::run_command_with_fspy` to provide\n/// a JavaScript-friendly interface for executing commands and tracking\n/// their file system accesses.\n///\n/// ## Parameters\n///\n/// - `options`: Configuration for the command to run, including:\n///   - `bin_name`: The name of the binary to execute\n///   - `args`: Command line arguments\n///   - `envs`: Environment variables\n///   - `cwd`: Working directory\n///\n/// ## Returns\n///\n/// Returns a `RunCommandResult` containing:\n/// - The exit code of the command\n/// - A map of file paths accessed and their access modes\n///\n/// ## Example\n///\n/// ```javascript\n/// const result = await runCommand({\n///   binName: \"node\",\n///   args: [\"-p\", \"console.log('hello')\"],\n///   envs: { PATH: process.env.PATH },\n///   cwd: \"/tmp\"\n/// });\n/// console.log(`Exit code: ${result.exitCode}`);\n/// console.log(`Path accesses:`, result.pathAccesses);\n/// ```\n#[napi]\npub async fn run_command(options: RunCommandOptions) -> Result<RunCommandResult> {\n    tracing::debug!(\"Run command options: {:?}\", options);\n    // Parse and validate the working directory\n    let cwd = AbsolutePathBuf::new(PathBuf::from(&options.cwd)).ok_or_else(|| {\n        anyhow::Error::msg(format!(\"Invalid working directory: {} (must be absolute)\", options.cwd))\n    })?;\n\n    // Convert args from Vec<String> to Vec<&str>\n    let args: Vec<&str> = options.args.iter().map(|s| s.as_str()).collect();\n\n    // Call the core run_command_with_fspy function\n    let result = run_command_with_fspy(&options.bin_name, &args, &options.envs, &cwd)\n        .await\n        .map_err(anyhow::Error::from)?;\n\n    // Convert path accesses to JavaScript-friendly format\n    let mut path_accesses = HashMap::new();\n    for (path, mode) in result.path_accesses {\n        path_accesses.insert(\n            path.as_str().to_string(),\n            PathAccess {\n                read: mode.contains(AccessMode::READ),\n                write: mode.contains(AccessMode::WRITE),\n                read_dir: mode.contains(AccessMode::READ_DIR),\n            },\n        );\n    }\n\n    // Get the exit code\n    let exit_code = result.status.code().unwrap_or(1);\n\n    Ok(RunCommandResult { exit_code, path_accesses })\n}\n"
  },
  {
    "path": "packages/cli/build.ts",
    "content": "/**\n * Build script for vite-plus CLI package\n *\n * This script performs the following main tasks:\n * 1. buildCli() - Compiles TypeScript sources (local CLI) via tsc\n * 2. buildGlobalModules() - Bundles global CLI modules (create, migrate, init, mcp, version) via rolldown\n * 3. buildNapiBinding() - Builds the native Rust binding via NAPI\n * 4. syncCorePackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-core\n * 5. syncTestPackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-test\n * 6. copySkillDocs() - Copies docs into skills/vite-plus/docs for runtime MCP access\n * 7. syncReadmeFromRoot() - Keeps package README in sync\n *\n * The sync functions allow this package to be a drop-in replacement for 'vite' by\n * re-exporting all the same subpaths (./client, ./types/*, etc.) while delegating\n * to the core package for actual implementation.\n *\n * IMPORTANT: The core package must be built before running this script.\n * Native binding is built first because TypeScript may depend on generated binding types.\n */\n\nimport { execSync } from 'node:child_process';\nimport { existsSync, globSync, readFileSync, readdirSync, statSync } from 'node:fs';\nimport { copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { parseArgs } from 'node:util';\n\nimport { createBuildCommand, NapiCli } from '@napi-rs/cli';\nimport { format } from 'oxfmt';\nimport {\n  createCompilerHost,\n  createProgram,\n  formatDiagnostics,\n  parseJsonSourceFileConfigFileContent,\n  readJsonConfigFile,\n  sys,\n  ModuleKind,\n} from 'typescript';\n\nimport { generateLicenseFile } from '../../scripts/generate-license.ts';\n\nconst projectDir = dirname(fileURLToPath(import.meta.url));\nconst TEST_PACKAGE_NAME = '@voidzero-dev/vite-plus-test';\nconst CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';\n\nconst {\n  values: { ['skip-native']: skipNative, ['skip-ts']: skipTs },\n} = parseArgs({\n  options: {\n    ['skip-native']: { type: 'boolean', default: false },\n    ['skip-ts']: { type: 'boolean', default: false },\n  },\n  strict: false,\n});\n\n// Filter out custom flags before passing to NAPI CLI\nconst napiArgs = process.argv\n  .slice(2)\n  .filter((arg) => arg !== '--skip-native' && arg !== '--skip-ts');\n\nif (!skipTs) {\n  await buildCli();\n  buildGlobalModules();\n  generateLicenseFile({\n    title: 'Vite-Plus CLI license',\n    packageName: 'Vite-Plus',\n    outputPath: join(projectDir, 'LICENSE'),\n    coreLicensePath: join(projectDir, '..', '..', 'LICENSE'),\n    bundledPaths: [join(projectDir, 'dist', 'global')],\n    resolveFrom: [projectDir],\n  });\n  if (!existsSync(join(projectDir, 'LICENSE'))) {\n    throw new Error('LICENSE was not generated during build');\n  }\n}\n// Build native first - TypeScript may depend on the generated binding types\nif (!skipNative) {\n  await buildNapiBinding();\n}\nif (!skipTs) {\n  await syncCorePackageExports();\n  await syncTestPackageExports();\n}\nawait copySkillDocs();\nawait syncReadmeFromRoot();\n\nasync function buildNapiBinding() {\n  const buildCommand = createBuildCommand(napiArgs);\n  const passedInOptions = buildCommand.getOptions();\n\n  const cli = new NapiCli();\n\n  const dtsHeader = process.env.RELEASE_BUILD\n    ? (await import('../../rolldown/packages/rolldown/package.json', { with: { type: 'json' } }))\n        .default.napi.dtsHeader\n    : '';\n\n  if (dtsHeader) {\n    passedInOptions.dtsHeader = `type BindingErrorsOr<T> = T | BindingErrors;\\ntype FxHashSet<T> = Set<T>;\\ntype FxHashMap<K, V> = Map<K, V>;\\n${dtsHeader}`;\n  }\n\n  const { task } = await cli.build({\n    ...passedInOptions,\n    packageJsonPath: '../package.json',\n    cwd: 'binding',\n    platform: true,\n    jsBinding: 'index.cjs',\n    dts: 'index.d.cts',\n    release: process.env.VITE_PLUS_CLI_DEBUG !== '1',\n    features: process.env.RELEASE_BUILD ? ['rolldown'] : void 0,\n  });\n\n  const outputs = await task;\n  const viteConfig = await import('../../vite.config');\n  for (const output of outputs) {\n    if (output.kind !== 'node') {\n      const { code, errors } = await format(output.path, await readFile(output.path, 'utf8'), {\n        ...viteConfig.default.fmt,\n        embeddedCode: true,\n      });\n      if (errors.length > 0) {\n        for (const error of errors) {\n          console.error(error);\n        }\n        process.exit(1);\n      }\n      await writeFile(output.path, code);\n    }\n  }\n}\n\nasync function buildCli() {\n  const tsconfig = readJsonConfigFile(join(projectDir, 'tsconfig.json'), sys.readFile.bind(sys));\n\n  const { options: initialOptions } = parseJsonSourceFileConfigFileContent(\n    tsconfig,\n    sys,\n    projectDir,\n  );\n\n  const options = {\n    ...initialOptions,\n    noEmit: false,\n    outDir: join(projectDir, 'dist'),\n  };\n\n  const cjsHost = createCompilerHost({\n    ...options,\n    module: ModuleKind.CommonJS,\n  });\n\n  const cjsProgram = createProgram({\n    rootNames: ['src/define-config.ts'],\n    options: {\n      ...options,\n      module: ModuleKind.CommonJS,\n    },\n    host: cjsHost,\n  });\n\n  const { diagnostics: cjsDiagnostics } = cjsProgram.emit();\n\n  if (cjsDiagnostics.length > 0) {\n    console.error(formatDiagnostics(cjsDiagnostics, cjsHost));\n    process.exit(1);\n  }\n  await rename(\n    join(projectDir, 'dist/define-config.js'),\n    join(projectDir, 'dist/define-config.cjs'),\n  );\n\n  const host = createCompilerHost(options);\n\n  const program = createProgram({\n    rootNames: globSync('src/**/*.{ts,cts}', {\n      cwd: projectDir,\n      exclude: [\n        '**/*/__tests__',\n        // Global CLI modules — bundled by rolldown instead of tsc\n        'src/create/**',\n        'src/init/**',\n        'src/mcp/**',\n        'src/migration/**',\n        'src/version.ts',\n        'src/types/**',\n      ],\n    }),\n    options,\n    host,\n  });\n\n  const { diagnostics } = program.emit();\n\n  if (diagnostics.length > 0) {\n    console.error(formatDiagnostics(diagnostics, host));\n    process.exit(1);\n  }\n}\n\nfunction buildGlobalModules() {\n  execSync('npx rolldown -c rolldown.config.ts', {\n    cwd: projectDir,\n    stdio: 'inherit',\n  });\n  validateGlobalBundleExternals();\n}\n\n/**\n * Scan rolldown output for unbundled workspace package imports.\n *\n * Rolldown silently externalizes imports it can't resolve (no error, no warning).\n * If a workspace package's dist doesn't exist at bundle time (build order race,\n * clean checkout, etc.), the bare specifier stays in the output. Since these\n * packages are devDependencies — not installed in the global CLI's node_modules —\n * this causes a runtime ERR_MODULE_NOT_FOUND crash.\n *\n * Fail the build loudly instead of producing a broken install.\n */\nfunction validateGlobalBundleExternals() {\n  const globalDir = join(projectDir, 'dist/global');\n  const files = globSync('*.js', { cwd: globalDir });\n  const errors: string[] = [];\n\n  for (const file of files) {\n    const content = readFileSync(join(globalDir, file), 'utf8');\n    const matches = content.matchAll(/\\bimport\\s.*?from\\s+[\"'](@voidzero-dev\\/[^\"']+)[\"']/g);\n    for (const match of matches) {\n      errors.push(`  ${file}: unbundled import of \"${match[1]}\"`);\n    }\n  }\n\n  if (errors.length > 0) {\n    throw new Error(\n      `Rolldown failed to bundle workspace packages in dist/global/:\\n${errors.join('\\n')}\\n` +\n        `Ensure these packages are built before running the CLI build.`,\n    );\n  }\n}\n\n/**\n * Sync Vite core exports from @voidzero-dev/vite-plus-core to vite-plus\n *\n * Creates shim files that re-export from the core package, enabling imports like:\n * - `import type { ... } from 'vite-plus/types/importGlob.d.ts'`\n * - `import { ... } from 'vite-plus/module-runner'`\n *\n * Export paths created:\n * - ./client - Triple-slash reference (ambient type declarations for CSS, assets, etc.)\n * - ./module-runner - Re-exports both JS and types\n * - ./internal - Re-exports both JS and types\n * - ./dist/client/* - Re-exports client runtime files (.mjs, .cjs)\n * - ./types/* - Type-only re-exports using `export type *`\n *\n * Note: In package.json exports, ./types/internal/* must come BEFORE ./types/*\n * for correct precedence (more specific patterns must precede wildcards).\n *\n * @throws Error if core package is not built (missing dist directories)\n */\nasync function syncCorePackageExports() {\n  console.log('\\nSyncing core package exports...');\n\n  const distDir = join(projectDir, 'dist');\n  const clientDir = join(distDir, 'client');\n  const typesDir = join(distDir, 'types');\n\n  // Clean up previous build\n  await rm(clientDir, { recursive: true, force: true });\n  await rm(typesDir, { recursive: true, force: true });\n  await mkdir(clientDir, { recursive: true });\n  await mkdir(typesDir, { recursive: true });\n\n  // Create ./client shim (types only) - uses triple-slash reference since client.d.ts is ambient\n  console.log('  Creating ./client');\n  await writeFile(\n    join(distDir, 'client.d.ts'),\n    `/// <reference types=\"${CORE_PACKAGE_NAME}/client\" />\\n`,\n  );\n\n  // Create ./module-runner shim\n  console.log('  Creating ./module-runner');\n  await writeFile(\n    join(distDir, 'module-runner.js'),\n    `export * from '${CORE_PACKAGE_NAME}/module-runner';\\n`,\n  );\n  await writeFile(\n    join(distDir, 'module-runner.d.ts'),\n    `export * from '${CORE_PACKAGE_NAME}/module-runner';\\n`,\n  );\n\n  // Create ./internal shim\n  console.log('  Creating ./internal');\n  await writeFile(join(distDir, 'internal.js'), `export * from '${CORE_PACKAGE_NAME}/internal';\\n`);\n  await writeFile(\n    join(distDir, 'internal.d.ts'),\n    `export * from '${CORE_PACKAGE_NAME}/internal';\\n`,\n  );\n\n  // Create ./dist/client/* shims by reading core's dist/vite/client files\n  console.log('  Creating ./dist/client/*');\n  const coreClientDir = join(projectDir, '../core/dist/vite/client');\n  if (!existsSync(coreClientDir)) {\n    throw new Error(\n      `Core client artifacts not found at \"${coreClientDir}\". ` +\n        `Make sure ${CORE_PACKAGE_NAME} is built before building the CLI.`,\n    );\n  }\n  for (const file of readdirSync(coreClientDir)) {\n    const srcPath = join(coreClientDir, file);\n    const shimPath = join(clientDir, file);\n    // Skip directories\n    if (statSync(srcPath).isDirectory()) {\n      continue;\n    }\n    if (file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.cjs')) {\n      await writeFile(shimPath, `export * from '${CORE_PACKAGE_NAME}/dist/client/${file}';\\n`);\n    } else if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) {\n      const baseFile = file.replace(/\\.d\\.[mc]?ts$/, '');\n      await writeFile(shimPath, `export * from '${CORE_PACKAGE_NAME}/dist/client/${baseFile}';\\n`);\n    } else {\n      // Copy non-JS/TS files directly (e.g., CSS, source maps)\n      await copyFile(srcPath, shimPath);\n    }\n  }\n\n  // Create ./types/* shims by reading core's dist/vite/types files\n  console.log('  Creating ./types/*');\n  const coreTypesDir = join(projectDir, '../core/dist/vite/types');\n  if (!existsSync(coreTypesDir)) {\n    throw new Error(\n      `Core type definitions not found at \"${coreTypesDir}\". ` +\n        `Make sure ${CORE_PACKAGE_NAME} is built before building the CLI.`,\n    );\n  }\n  await syncTypesDir(coreTypesDir, typesDir, '');\n\n  console.log('\\nSynced core package exports');\n}\n\n/**\n * Recursively sync type definition files from core to CLI package\n *\n * Creates shim .d.ts files that re-export types from the core package.\n * Uses `export type * from` syntax which is valid in TypeScript 5.0+.\n *\n * @param srcDir - Source directory containing .d.ts files\n * @param destDir - Destination directory for shim files\n * @param relativePath - Current path relative to types root (empty string at top level)\n *\n * Special handling:\n * - Skips top-level 'internal' directory (blocked by ./types/internal/* export)\n * - Supports .d.ts, .d.mts, and .d.cts extensions\n * - Preserves directory structure recursively\n */\nasync function syncTypesDir(srcDir: string, destDir: string, relativePath: string) {\n  const entries = readdirSync(srcDir);\n\n  for (const entry of entries) {\n    const srcPath = join(srcDir, entry);\n    const destPath = join(destDir, entry);\n    const entryRelPath = relativePath ? `${relativePath}/${entry}` : entry;\n\n    if (statSync(srcPath).isDirectory()) {\n      // Skip top-level internal directory - it's blocked by ./types/internal/* export\n      if (entry === 'internal' && relativePath === '') {\n        continue;\n      }\n\n      await mkdir(destPath, { recursive: true });\n      await syncTypesDir(srcPath, destPath, entryRelPath);\n    } else if (/\\.d\\.[mc]?ts$/.test(entry)) {\n      // Create shim that re-exports from core - must include extension for wildcard exports\n      // Use 'export type *' since we're re-exporting from a .d.ts file\n      await writeFile(\n        destPath,\n        `export type * from '${CORE_PACKAGE_NAME}/types/${entryRelPath}';\\n`,\n      );\n    }\n  }\n}\n\n/**\n * Sync exports from @voidzero-dev/vite-plus-test to vite-plus\n *\n * This function reads the test package's exports and creates shim files that\n * re-export everything under the ./test/* subpath. This allows users to import\n * from vite-plus/test/* instead of @voidzero-dev/vite-plus-test/*.\n */\nasync function syncTestPackageExports() {\n  console.log('\\nSyncing test package exports...');\n\n  const testPkgPath = join(projectDir, '../test/package.json');\n  const cliPkgPath = join(projectDir, 'package.json');\n  const testDistDir = join(projectDir, 'dist/test');\n\n  // Read test package.json\n  const testPkg = JSON.parse(await readFile(testPkgPath, 'utf-8'));\n  const testExports = testPkg.exports as Record<string, unknown>;\n\n  // Clean up previous build\n  await rm(testDistDir, { recursive: true, force: true });\n  await mkdir(testDistDir, { recursive: true });\n\n  const generatedExports: Record<string, unknown> = {};\n\n  for (const [exportPath, exportValue] of Object.entries(testExports)) {\n    // Skip package.json export and wildcard exports\n    if (exportPath === './package.json' || exportPath.includes('*')) {\n      continue;\n    }\n\n    // Convert ./foo to ./test/foo, . to ./test\n    const cliExportPath = exportPath === '.' ? './test' : `./test${exportPath.slice(1)}`;\n\n    // Create shim files and build export entry\n    const shimExport = await createShimForExport(exportPath, exportValue, testDistDir);\n    if (shimExport) {\n      generatedExports[cliExportPath] = shimExport;\n      console.log(`  Created ${cliExportPath}`);\n    }\n  }\n\n  // Update CLI package.json\n  await updateCliPackageJson(cliPkgPath, generatedExports);\n\n  console.log(`\\nSynced ${Object.keys(generatedExports).length} exports from test package`);\n}\n\n/**\n * Copy markdown doc files from the monorepo docs/ directory into skills/vite-plus/docs/,\n * preserving the relative directory structure. This keeps stable file paths for\n * skills routing and MCP page slugs.\n */\nasync function copySkillDocs() {\n  console.log('\\nCopying skill docs...');\n\n  const docsSourceDir = join(projectDir, '..', '..', 'docs');\n  const docsTargetDir = join(projectDir, 'skills', 'vite-plus', 'docs');\n\n  if (!existsSync(docsSourceDir)) {\n    console.log('  Docs source directory not found, skipping skill docs copy');\n    return;\n  }\n\n  // Clean and recreate target directory\n  await rm(docsTargetDir, { recursive: true, force: true });\n  await mkdir(docsTargetDir, { recursive: true });\n\n  // Find all markdown files recursively and copy them with their relative paths.\n  const mdFiles = globSync('**/*.md', { cwd: docsSourceDir }).filter(\n    (f) => !f.includes('node_modules') && f !== 'index.md',\n  );\n  // eslint-disable-next-line unicorn/no-array-sort -- sorted traversal keeps output deterministic\n  mdFiles.sort();\n\n  let copied = 0;\n  for (const relPath of mdFiles) {\n    const sourcePath = join(docsSourceDir, relPath);\n    const targetPath = join(docsTargetDir, relPath);\n    await mkdir(dirname(targetPath), { recursive: true });\n    await copyFile(sourcePath, targetPath);\n    copied++;\n  }\n\n  console.log(`  Copied ${copied} doc files to skills/vite-plus/docs/ (with paths preserved)`);\n}\n\nasync function syncReadmeFromRoot() {\n  const rootReadmePath = join(projectDir, '..', '..', 'README.md');\n  const packageReadmePath = join(projectDir, 'README.md');\n  const [rootReadme, packageReadme] = await Promise.all([\n    readFile(rootReadmePath, 'utf8'),\n    readFile(packageReadmePath, 'utf8'),\n  ]);\n\n  const { suffix: rootSuffix } = splitReadme(rootReadme, rootReadmePath);\n  const { prefix: packagePrefix } = splitReadme(packageReadme, packageReadmePath);\n  const nextReadme = `${packagePrefix}\\n\\n${rootSuffix}\\n`;\n\n  if (nextReadme !== packageReadme) {\n    await writeFile(packageReadmePath, nextReadme);\n  }\n}\n\nfunction splitReadme(content: string, label: string) {\n  const match = /^---\\s*$/m.exec(content);\n  if (!match || match.index === undefined) {\n    throw new Error(`Expected ${label} to include a '---' separator.`);\n  }\n\n  const delimiterStart = match.index;\n  const delimiterEnd = delimiterStart + match[0].length;\n  const afterDelimiter = content.slice(delimiterEnd);\n  const newlineMatch = /^\\r?\\n/.exec(afterDelimiter);\n  const delimiterWithNewlineEnd = delimiterEnd + (newlineMatch ? newlineMatch[0].length : 0);\n\n  return {\n    prefix: content.slice(0, delimiterWithNewlineEnd).trim(),\n    suffix: content.slice(delimiterWithNewlineEnd).trim(),\n  };\n}\n\ntype ExportValue =\n  | string\n  | {\n      types?: string;\n      default?: string;\n      import?: ExportValue;\n      require?: ExportValue;\n      node?: string;\n    };\n\n/**\n * Create shim file(s) for a single export and return the export entry for package.json\n */\nasync function createShimForExport(\n  exportPath: string,\n  exportValue: unknown,\n  distDir: string,\n): Promise<ExportValue | null> {\n  // Determine the import specifier for the test package\n  const testImportSpecifier =\n    exportPath === '.' ? TEST_PACKAGE_NAME : `${TEST_PACKAGE_NAME}${exportPath.slice(1)}`;\n\n  // Convert export path to file path: ./foo/bar -> foo/bar, . -> index\n  const shimBaseName = exportPath === '.' ? 'index' : exportPath.slice(2);\n  const shimDir = join(distDir, dirname(shimBaseName));\n  await mkdir(shimDir, { recursive: true });\n\n  const baseFileName = shimBaseName.includes('/') ? shimBaseName.split('/').pop()! : shimBaseName;\n  const shimDirForFile = shimBaseName.includes('/') ? shimDir : distDir;\n\n  // Handle different export value formats\n  if (typeof exportValue === 'string') {\n    // Simple string export: \"./browser-compat\": \"./dist/browser-compat.js\"\n    // Check if it's a type-only export\n    if (exportValue.endsWith('.d.ts')) {\n      const dtsPath = join(shimDirForFile, `${baseFileName}.d.ts`);\n      // Include side-effect import to preserve module augmentations (e.g., toMatchSnapshot on Assertion)\n      await writeFile(\n        dtsPath,\n        `import '${testImportSpecifier}';\\nexport * from '${testImportSpecifier}';\\n`,\n      );\n      return { types: `./dist/test/${shimBaseName}.d.ts` };\n    }\n\n    const jsPath = join(shimDirForFile, `${baseFileName}.js`);\n    await writeFile(jsPath, `export * from '${testImportSpecifier}';\\n`);\n    return { default: `./dist/test/${shimBaseName}.js` };\n  }\n\n  if (typeof exportValue === 'object' && exportValue !== null) {\n    const value = exportValue as Record<string, unknown>;\n\n    // Check if it has import/require conditions (complex conditional export)\n    if ('import' in value || 'require' in value) {\n      return await createConditionalShim(\n        value,\n        testImportSpecifier,\n        shimDirForFile,\n        baseFileName,\n        shimBaseName,\n      );\n    }\n\n    // Simple object with types/default\n    const result: ExportValue = {};\n\n    if (value.types && typeof value.types === 'string') {\n      const dtsPath = join(shimDirForFile, `${baseFileName}.d.ts`);\n      // Include side-effect import to preserve module augmentations (e.g., toMatchSnapshot on Assertion)\n      await writeFile(\n        dtsPath,\n        `import '${testImportSpecifier}';\\nexport * from '${testImportSpecifier}';\\n`,\n      );\n      (result as Record<string, string>).types = `./dist/test/${shimBaseName}.d.ts`;\n    }\n\n    if (value.default && typeof value.default === 'string') {\n      const jsPath = join(shimDirForFile, `${baseFileName}.js`);\n      await writeFile(jsPath, `export * from '${testImportSpecifier}';\\n`);\n      (result as Record<string, string>).default = `./dist/test/${shimBaseName}.js`;\n    }\n\n    return Object.keys(result).length > 0 ? result : null;\n  }\n\n  return null;\n}\n\n/**\n * Handle complex conditional exports with import/require/node conditions\n *\n * Handles both nested structures like:\n *   { import: { types, node, default }, require: { types, default } }\n * And flat structures like:\n *   { types, require, default }\n */\nasync function createConditionalShim(\n  value: Record<string, unknown>,\n  testImportSpecifier: string,\n  shimDir: string,\n  baseFileName: string,\n  shimBaseName: string,\n): Promise<ExportValue> {\n  const result: ExportValue = {};\n\n  // Handle top-level types (flat structure like { types, require, default })\n  if (value.types && typeof value.types === 'string' && !value.import) {\n    const dtsPath = join(shimDir, `${baseFileName}.d.ts`);\n    // Include side-effect import to preserve module augmentations (e.g., toMatchSnapshot on Assertion)\n    await writeFile(\n      dtsPath,\n      `import '${testImportSpecifier}';\\nexport * from '${testImportSpecifier}';\\n`,\n    );\n    (result as Record<string, string>).types = `./dist/test/${shimBaseName}.d.ts`;\n  }\n\n  // Handle top-level default (flat structure, only when no import condition)\n  if (value.default && typeof value.default === 'string' && !value.import) {\n    const jsPath = join(shimDir, `${baseFileName}.js`);\n    await writeFile(jsPath, `export * from '${testImportSpecifier}';\\n`);\n    (result as Record<string, string>).default = `./dist/test/${shimBaseName}.js`;\n  }\n\n  // Handle import condition\n  if (value.import) {\n    const importValue = value.import as Record<string, unknown>;\n\n    if (typeof importValue === 'string') {\n      const jsPath = join(shimDir, `${baseFileName}.js`);\n      await writeFile(jsPath, `export * from '${testImportSpecifier}';\\n`);\n      (result as Record<string, unknown>).import = `./dist/test/${shimBaseName}.js`;\n    } else if (typeof importValue === 'object' && importValue !== null) {\n      const importResult: Record<string, string> = {};\n\n      if (importValue.types && typeof importValue.types === 'string') {\n        const dtsPath = join(shimDir, `${baseFileName}.d.ts`);\n        // Include side-effect import to preserve module augmentations (e.g., toMatchSnapshot on Assertion)\n        await writeFile(\n          dtsPath,\n          `import '${testImportSpecifier}';\\nexport * from '${testImportSpecifier}';\\n`,\n        );\n        importResult.types = `./dist/test/${shimBaseName}.d.ts`;\n      }\n\n      // Create main JS shim - used for both 'node' and 'default' conditions\n      const jsPath = join(shimDir, `${baseFileName}.js`);\n      await writeFile(jsPath, `export * from '${testImportSpecifier}';\\n`);\n\n      if (importValue.node) {\n        importResult.node = `./dist/test/${shimBaseName}.js`;\n      }\n      if (importValue.default) {\n        importResult.default = `./dist/test/${shimBaseName}.js`;\n      }\n\n      result.import = importResult;\n    }\n  }\n\n  // Handle require condition\n  if (value.require) {\n    const requireValue = value.require as Record<string, unknown>;\n\n    if (typeof requireValue === 'string') {\n      const cjsPath = join(shimDir, `${baseFileName}.cjs`);\n      await writeFile(cjsPath, `module.exports = require('${testImportSpecifier}');\\n`);\n      result.require = `./dist/test/${shimBaseName}.cjs`;\n    } else if (typeof requireValue === 'object' && requireValue !== null) {\n      const requireResult: Record<string, string> = {};\n\n      if (requireValue.types && typeof requireValue.types === 'string') {\n        const dctsPath = join(shimDir, `${baseFileName}.d.cts`);\n        // Include side-effect import to preserve module augmentations (e.g., toMatchSnapshot on Assertion)\n        await writeFile(\n          dctsPath,\n          `import '${testImportSpecifier}';\\nexport * from '${testImportSpecifier}';\\n`,\n        );\n        requireResult.types = `./dist/test/${shimBaseName}.d.cts`;\n      }\n\n      if (requireValue.default && typeof requireValue.default === 'string') {\n        const cjsPath = join(shimDir, `${baseFileName}.cjs`);\n        await writeFile(cjsPath, `module.exports = require('${testImportSpecifier}');\\n`);\n        requireResult.default = `./dist/test/${shimBaseName}.cjs`;\n      }\n\n      result.require = requireResult;\n    }\n  }\n\n  return result;\n}\n\n/**\n * Update CLI package.json with the generated exports\n */\nasync function updateCliPackageJson(pkgPath: string, generatedExports: Record<string, unknown>) {\n  const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));\n\n  // Remove old ./test/* exports (if any) to ensure clean sync\n  if (pkg.exports) {\n    for (const key of Object.keys(pkg.exports)) {\n      if (key.startsWith('./test')) {\n        delete pkg.exports[key];\n      }\n    }\n  }\n\n  // Add new exports\n  pkg.exports = {\n    ...pkg.exports,\n    ...generatedExports,\n  };\n\n  // Ensure dist/test is included in files\n  if (!pkg.files.includes('dist/test')) {\n    pkg.files.push('dist/test');\n  }\n\n  const { code, errors } = await format(pkgPath, JSON.stringify(pkg, null, 2) + '\\n', {\n    sortPackageJson: true,\n  });\n  if (errors.length > 0) {\n    for (const error of errors) {\n      console.error(error);\n    }\n    process.exit(1);\n  }\n\n  await writeFile(pkgPath, code);\n}\n"
  },
  {
    "path": "packages/cli/install.ps1",
    "content": "# Vite+ CLI Installer for Windows\n# https://vite.plus/ps1\n#\n# Usage:\n#   irm https://vite.plus/ps1 | iex\n#\n# Environment variables:\n#   VITE_PLUS_VERSION - Version to install (default: latest)\n#   VITE_PLUS_HOME - Installation directory (default: $env:USERPROFILE\\.vite-plus)\n#   NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org)\n#   VITE_PLUS_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing)\n\n$ErrorActionPreference = \"Stop\"\n\n$ViteVersion = if ($env:VITE_PLUS_VERSION) { $env:VITE_PLUS_VERSION } else { \"latest\" }\n$InstallDir = if ($env:VITE_PLUS_HOME) { $env:VITE_PLUS_HOME } else { \"$env:USERPROFILE\\.vite-plus\" }\n# npm registry URL (strip trailing slash if present)\n$NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { \"https://registry.npmjs.org\" }\n# Local tarball for development/testing\n$LocalTgz = $env:VITE_PLUS_LOCAL_TGZ\n# Local binary path (set by install-global-cli.ts for local dev)\n$LocalBinary = $env:VITE_PLUS_LOCAL_BINARY\n\nfunction Write-Info {\n    param([string]$Message)\n    Write-Host \"info: \" -ForegroundColor Blue -NoNewline\n    Write-Host $Message\n}\n\nfunction Write-Success {\n    param([string]$Message)\n    Write-Host \"success: \" -ForegroundColor Green -NoNewline\n    Write-Host $Message\n}\n\nfunction Write-Warn {\n    param([string]$Message)\n    Write-Host \"warn: \" -ForegroundColor Yellow -NoNewline\n    Write-Host $Message\n}\n\nfunction Write-Error-Exit {\n    param([string]$Message)\n    Write-Host \"error: \" -ForegroundColor Red -NoNewline\n    Write-Host $Message\n    exit 1\n}\n\nfunction Get-Architecture {\n    if ([Environment]::Is64BitOperatingSystem) {\n        if ($env:PROCESSOR_ARCHITECTURE -eq \"ARM64\") {\n            return \"arm64\"\n        } else {\n            return \"x64\"\n        }\n    } else {\n        Write-Error-Exit \"32-bit Windows is not supported\"\n    }\n}\n\n# Cached package metadata\n$script:PackageMetadata = $null\n\nfunction Get-PackageMetadata {\n    if ($null -eq $script:PackageMetadata) {\n        $versionPath = if ($ViteVersion -eq \"latest\") { \"latest\" } else { $ViteVersion }\n        $metadataUrl = \"$NpmRegistry/vite-plus/$versionPath\"\n        try {\n            $script:PackageMetadata = Invoke-RestMethod $metadataUrl\n        } catch {\n            # Try to extract npm error message from response\n            $errorMsg = $_.ErrorDetails.Message\n            if ($errorMsg) {\n                try {\n                    $errorJson = $errorMsg | ConvertFrom-Json\n                    if ($errorJson.error) {\n                        Write-Error-Exit \"Failed to fetch version '${versionPath}': $($errorJson.error)\"\n                    }\n                } catch {\n                    # JSON parsing failed, fall through to generic error\n                }\n            }\n            Write-Error-Exit \"Failed to fetch package metadata from: $metadataUrl`nError: $_\"\n        }\n        # Check for error in successful response\n        # npm can return {\"error\":\"...\"} object or a plain string like \"version not found: test\"\n        if ($script:PackageMetadata -is [string]) {\n            # Plain string response means error\n            Write-Error-Exit \"Failed to fetch version '${versionPath}': $script:PackageMetadata\"\n        }\n        if ($script:PackageMetadata.error) {\n            Write-Error-Exit \"Failed to fetch version '${versionPath}': $($script:PackageMetadata.error)\"\n        }\n    }\n    return $script:PackageMetadata\n}\n\nfunction Get-VersionFromMetadata {\n    $metadata = Get-PackageMetadata\n    if (-not $metadata.version) {\n        Write-Error-Exit \"Failed to extract version from package metadata\"\n    }\n    return $metadata.version\n}\n\nfunction Get-PlatformSuffix {\n    param([string]$Platform)\n    # Windows needs -msvc suffix, other platforms map directly\n    if ($Platform.StartsWith(\"win32-\")) { return \"${Platform}-msvc\" }\n    return $Platform\n}\n\nfunction Download-AndExtract {\n    param(\n        [string]$Url,\n        [string]$DestDir,\n        [string]$Filter\n    )\n\n    $tempFile = New-TemporaryFile\n    try {\n        # Suppress progress bar for cleaner output\n        $ProgressPreference = 'SilentlyContinue'\n        Invoke-WebRequest -Uri $Url -OutFile $tempFile\n\n        # Create temp extraction directory\n        $tempExtract = Join-Path $env:TEMP \"vite-install-$(Get-Random)\"\n        New-Item -ItemType Directory -Force -Path $tempExtract | Out-Null\n\n        # Extract using tar (available in Windows 10+)\n        & \"$env:SystemRoot\\System32\\tar.exe\" -xzf $tempFile -C $tempExtract\n\n        # Copy the specified file/directory\n        $sourcePath = Join-Path (Join-Path $tempExtract \"package\") $Filter\n        if (Test-Path $sourcePath) {\n            Copy-Item -Path $sourcePath -Destination $DestDir -Recurse -Force\n        }\n\n        Remove-Item -Recurse -Force $tempExtract\n    } finally {\n        Remove-Item $tempFile -ErrorAction SilentlyContinue\n    }\n}\n\nfunction Cleanup-OldVersions {\n    param([string]$InstallDir)\n\n    $maxVersions = 5\n    # Only cleanup semver format directories (0.1.0, 1.2.3-beta.1, etc.)\n    # This excludes 'current' symlink and non-semver directories like 'local-dev'\n    $semverPattern = '^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?$'\n    $versions = Get-ChildItem -Path $InstallDir -Directory -ErrorAction SilentlyContinue |\n        Where-Object { $_.Name -match $semverPattern }\n\n    if ($null -eq $versions -or $versions.Count -le $maxVersions) {\n        return\n    }\n\n    # Sort by creation time (oldest first) and select excess\n    $toDelete = $versions |\n        Sort-Object CreationTime |\n        Select-Object -First ($versions.Count - $maxVersions)\n\n    foreach ($old in $toDelete) {\n        # Remove silently\n        Remove-Item -Path $old.FullName -Recurse -Force\n    }\n}\n\n# Configure user PATH for ~/.vite-plus/bin\n# Returns: \"true\" = added, \"already\" = already configured\nfunction Configure-UserPath {\n    $binPath = \"$InstallDir\\bin\"\n    $userPath = [Environment]::GetEnvironmentVariable(\"Path\", \"User\")\n\n    if ($userPath -like \"*$binPath*\") {\n        return \"already\"\n    }\n\n    $newPath = \"$binPath;$userPath\"\n    try {\n        [Environment]::SetEnvironmentVariable(\"Path\", $newPath, \"User\")\n        $env:Path = \"$binPath;$env:Path\"\n        return \"true\"\n    } catch {\n        Write-Warn \"Could not update user PATH automatically.\"\n        return \"failed\"\n    }\n}\n\n# Run vp env setup --refresh, showing output only on failure\nfunction Refresh-Shims {\n    param([string]$BinDir)\n    $setupOutput = & \"$BinDir\\vp.exe\" env setup --refresh 2>&1\n    if ($LASTEXITCODE -ne 0) {\n        Write-Warn \"Failed to refresh shims:\"\n        Write-Host \"$setupOutput\"\n    }\n}\n\n# Setup Node.js version manager (node/npm/npx shims)\n# Returns: \"true\" = enabled, \"false\" = not enabled, \"already\" = already configured\nfunction Setup-NodeManager {\n    param([string]$BinDir)\n\n    $binPath = \"$InstallDir\\bin\"\n\n    # Explicit override via environment variable\n    if ($env:VITE_PLUS_NODE_MANAGER -eq \"yes\") {\n        Refresh-Shims -BinDir $BinDir\n        return \"true\"\n    } elseif ($env:VITE_PLUS_NODE_MANAGER -eq \"no\") {\n        return \"false\"\n    }\n\n    # Check if Vite+ is already managing Node.js (bin\\node.exe exists)\n    if (Test-Path \"$binPath\\node.exe\") {\n        # Already managing Node.js, just refresh shims\n        Refresh-Shims -BinDir $BinDir\n        return \"already\"\n    }\n\n    # Auto-enable on CI or devcontainer environments\n    # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.)\n    # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces)\n    # REMOTE_CONTAINERS: set by VS Code Dev Containers extension\n    # DEVPOD: set by DevPod (https://devpod.sh)\n    if ($env:CI -or $env:CODESPACES -or $env:REMOTE_CONTAINERS -or $env:DEVPOD) {\n        Refresh-Shims -BinDir $BinDir\n        return \"true\"\n    }\n\n    # Check if node is available on the system\n    $nodeAvailable = $null -ne (Get-Command node -ErrorAction SilentlyContinue)\n\n    # Auto-enable if no node available on system\n    if (-not $nodeAvailable) {\n        Refresh-Shims -BinDir $BinDir\n        return \"true\"\n    }\n\n    # Prompt user in interactive mode\n    $isInteractive = [Environment]::UserInteractive\n    if ($isInteractive) {\n        Write-Host \"\"\n        Write-Host \"Would you want Vite+ to manage Node.js versions?\"\n        $response = Read-Host \"Press Enter to accept (Y/n)\"\n\n        if ($response -eq '' -or $response -eq 'y' -or $response -eq 'Y') {\n            Refresh-Shims -BinDir $BinDir\n            return \"true\"\n        }\n    }\n\n    return \"false\"\n}\n\nfunction Main {\n    Write-Host \"\"\n    Write-Host \"Setting up \" -NoNewline\n    Write-Host \"VITE+\" -ForegroundColor Blue -NoNewline\n    Write-Host \"...\"\n\n    # Suppress progress bars for cleaner output\n    $ProgressPreference = 'SilentlyContinue'\n\n    $arch = Get-Architecture\n    $platform = \"win32-$arch\"\n\n    # Local development mode: use local tgz\n    if ($LocalTgz) {\n        # Validate local tgz\n        if (-not (Test-Path $LocalTgz)) {\n            Write-Error-Exit \"Local tarball not found: $LocalTgz\"\n        }\n        # Use version as-is (default to \"local-dev\")\n        if ($ViteVersion -eq \"latest\" -or $ViteVersion -eq \"test\") {\n            $ViteVersion = \"local-dev\"\n        }\n    } else {\n        # Fetch package metadata and resolve version from npm\n        $ViteVersion = Get-VersionFromMetadata\n    }\n\n    # Set up version-specific directories\n    $VersionDir = \"$InstallDir\\$ViteVersion\"\n    $BinDir = \"$VersionDir\\bin\"\n    $CurrentLink = \"$InstallDir\\current\"\n\n    $binaryName = \"vp.exe\"\n\n    # Create bin directory\n    New-Item -ItemType Directory -Force -Path $BinDir | Out-Null\n\n    if ($LocalTgz) {\n        # Local development mode: only need the binary\n        Write-Info \"Using local tarball: $LocalTgz\"\n\n        # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts)\n        if ($LocalBinary -and (Test-Path $LocalBinary)) {\n            Copy-Item -Path $LocalBinary -Destination (Join-Path $BinDir $binaryName) -Force\n            # Also copy trampoline shim binary if available (sibling to vp.exe)\n            $shimSource = Join-Path (Split-Path $LocalBinary) \"vp-shim.exe\"\n            if (Test-Path $shimSource) {\n                Copy-Item -Path $shimSource -Destination (Join-Path $BinDir \"vp-shim.exe\") -Force\n            }\n        } else {\n            Write-Error-Exit \"VITE_PLUS_LOCAL_BINARY must be set when using VITE_PLUS_LOCAL_TGZ\"\n        }\n    } else {\n        # Download from npm registry — extract only the vp binary from CLI platform package\n        $platformSuffix = Get-PlatformSuffix -Platform $platform\n        $packageName = \"@voidzero-dev/vite-plus-cli-$platformSuffix\"\n        $platformUrl = \"$NpmRegistry/$packageName/-/vite-plus-cli-$platformSuffix-$ViteVersion.tgz\"\n\n        $platformTempFile = New-TemporaryFile\n        try {\n            Invoke-WebRequest -Uri $platformUrl -OutFile $platformTempFile\n\n            # Create temp extraction directory\n            $platformTempExtract = Join-Path $env:TEMP \"vite-platform-$(Get-Random)\"\n            New-Item -ItemType Directory -Force -Path $platformTempExtract | Out-Null\n\n            # Extract the package\n            & \"$env:SystemRoot\\System32\\tar.exe\" -xzf $platformTempFile -C $platformTempExtract\n\n            # Copy binary to BinDir\n            $packageDir = Join-Path $platformTempExtract \"package\"\n            $binarySource = Join-Path $packageDir $binaryName\n            if (Test-Path $binarySource) {\n                Copy-Item -Path $binarySource -Destination $BinDir -Force\n            }\n            # Also copy trampoline shim binary if present in the package\n            $shimSource = Join-Path $packageDir \"vp-shim.exe\"\n            if (Test-Path $shimSource) {\n                Copy-Item -Path $shimSource -Destination $BinDir -Force\n            }\n\n            Remove-Item -Recurse -Force $platformTempExtract\n        } finally {\n            Remove-Item $platformTempFile -ErrorAction SilentlyContinue\n        }\n    }\n\n    # Generate wrapper package.json that declares vite-plus as a dependency.\n    # npm will install vite-plus and all transitive deps via `vp install`.\n    $wrapperJson = @{\n        name = \"vp-global\"\n        version = $ViteVersion\n        private = $true\n        dependencies = @{\n            \"vite-plus\" = $ViteVersion\n        }\n    } | ConvertTo-Json -Depth 10\n    Set-Content -Path (Join-Path $VersionDir \"package.json\") -Value $wrapperJson\n\n    # Isolate from user's global package manager config that may block\n    # installing recently-published packages (e.g. pnpm's minimumReleaseAge,\n    # npm's min-release-age) by creating a local .npmrc in the version directory.\n    Set-Content -Path (Join-Path $VersionDir \".npmrc\") -Value \"minimum-release-age=0`nmin-release-age=0\"\n\n    # Install production dependencies (skip if VITE_PLUS_SKIP_DEPS_INSTALL is set,\n    # e.g. during local dev where install-global-cli.ts handles deps separately)\n    if (-not $env:VITE_PLUS_SKIP_DEPS_INSTALL) {\n        $installLog = Join-Path $VersionDir \"install.log\"\n        Push-Location $VersionDir\n        try {\n            $env:CI = \"true\"\n            & \"$BinDir\\vp.exe\" install --silent *> $installLog\n            if ($LASTEXITCODE -ne 0) {\n                Write-Host \"error: Failed to install dependencies. See log for details: $installLog\" -ForegroundColor Red\n                exit 1\n            }\n        } finally {\n            Pop-Location\n        }\n    }\n\n    # Create/update current junction (symlink)\n    if (Test-Path $CurrentLink) {\n        # Remove existing junction\n        cmd /c rmdir \"$CurrentLink\" 2>$null\n        Remove-Item -Path $CurrentLink -Force -ErrorAction SilentlyContinue\n    }\n    # Create new junction pointing to the version directory\n    cmd /c mklink /J \"$CurrentLink\" \"$VersionDir\" | Out-Null\n\n    # Create bin directory and vp wrapper (always done)\n    New-Item -ItemType Directory -Force -Path \"$InstallDir\\bin\" | Out-Null\n    $trampolineSrc = \"$VersionDir\\bin\\vp-shim.exe\"\n    if (Test-Path $trampolineSrc) {\n        # New versions: use trampoline exe to avoid \"Terminate batch job (Y/N)?\" on Ctrl+C\n        Copy-Item -Path $trampolineSrc -Destination \"$InstallDir\\bin\\vp.exe\" -Force\n        # Remove legacy .cmd and shell script wrappers from previous versions\n        foreach ($legacy in @(\"$InstallDir\\bin\\vp.cmd\", \"$InstallDir\\bin\\vp\")) {\n            if (Test-Path $legacy) {\n                Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue\n            }\n        }\n    } else {\n        # Pre-trampoline versions: fall back to legacy .cmd and shell script wrappers.\n        # Remove any stale trampoline .exe shims left by a newer install — .exe wins\n        # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers.\n        foreach ($stale in @(\"vp.exe\", \"node.exe\", \"npm.exe\", \"npx.exe\", \"vpx.exe\")) {\n            $stalePath = Join-Path \"$InstallDir\\bin\" $stale\n            if (Test-Path $stalePath) {\n                Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue\n            }\n        }\n        # Keep consistent with the original install.ps1 wrapper format\n        $wrapperContent = @\"\n@echo off\nset VITE_PLUS_HOME=%~dp0..\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" %*\nexit /b %ERRORLEVEL%\n\"@\n        Set-Content -Path \"$InstallDir\\bin\\vp.cmd\" -Value $wrapperContent -NoNewline\n\n        # Also create shell script wrapper for Git Bash/MSYS\n        $shContent = @\"\n#!/bin/sh\nVITE_PLUS_HOME=\"`$(dirname \"`$(dirname \"`$(readlink -f \"`$0\" 2>/dev/null || echo \"`$0\")\")\")\"\nexport VITE_PLUS_HOME\nexec \"`$VITE_PLUS_HOME/current/bin/vp.exe\" \"`$@\"\n\"@\n        Set-Content -Path \"$InstallDir\\bin\\vp\" -Value $shContent -NoNewline\n    }\n\n    # Cleanup old versions\n    Cleanup-OldVersions -InstallDir $InstallDir\n\n    # Configure user PATH (always attempted)\n    $pathResult = Configure-UserPath\n\n    # Setup Node.js version manager (shims) - separate component\n    $nodeManagerResult = Setup-NodeManager -BinDir $BinDir\n\n    # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path\n    $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~'\n\n    # ANSI color codes for consistent output\n    $e = [char]27\n    $GREEN = \"$e[32m\"\n    $YELLOW = \"$e[33m\"\n    $BRIGHT_BLUE = \"$e[94m\"\n    $BOLD = \"$e[1m\"\n    $DIM = \"$e[2m\"\n    $BOLD_BRIGHT_BLUE = \"$e[1;94m\"\n    $NC = \"$e[0m\"\n    $CHECKMARK = [char]0x2714\n\n    # Print success message\n    Write-Host \"\"\n    Write-Host \"${GREEN}${CHECKMARK}${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!\"\n    Write-Host \"\"\n    Write-Host \"  The Unified Toolchain for the Web.\"\n    Write-Host \"\"\n    Write-Host \"  ${BOLD}Get started:${NC}\"\n    Write-Host \"    ${BRIGHT_BLUE}vp create${NC}       Create a new project\"\n    Write-Host \"    ${BRIGHT_BLUE}vp env${NC}          Manage Node.js versions\"\n    Write-Host \"    ${BRIGHT_BLUE}vp install${NC}      Install dependencies\"\n    Write-Host \"    ${BRIGHT_BLUE}vp migrate${NC}      Migrate to Vite+\"\n\n    # Show Node.js manager status\n    if ($nodeManagerResult -eq \"true\" -or $nodeManagerResult -eq \"already\") {\n        Write-Host \"\"\n        Write-Host \"  Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}.\"\n        Write-Host \"  Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out.\"\n    }\n\n    Write-Host \"\"\n    Write-Host \"  Run ${BRIGHT_BLUE}vp help${NC} to see available commands.\"\n\n    # Show note if PATH was updated\n    if ($pathResult -eq \"true\") {\n        Write-Host \"\"\n        Write-Host \"  Note: Restart your terminal and IDE for changes to take effect.\"\n    }\n\n    # Show manual PATH instructions if PATH could not be configured\n    if ($pathResult -eq \"failed\") {\n        Write-Host \"\"\n        Write-Host \"  ${YELLOW}note${NC}: Could not automatically add vp to your PATH.\"\n        Write-Host \"\"\n        Write-Host \"  vp was installed to: ${BOLD}${displayDir}\\bin${NC}\"\n        Write-Host \"\"\n        Write-Host \"  To use vp, manually add it to your PATH:\"\n        Write-Host \"\"\n        Write-Host \"    [Environment]::SetEnvironmentVariable('Path', '$InstallDir\\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')\"\n        Write-Host \"\"\n        Write-Host \"  Or run vp directly:\"\n        Write-Host \"\"\n        Write-Host \"    & `\"$InstallDir\\bin\\vp.exe`\"\"\n    }\n\n    Write-Host \"\"\n}\n\nMain\n"
  },
  {
    "path": "packages/cli/install.sh",
    "content": "#!/bin/bash\n# Vite+ CLI Installer\n# https://vite.plus\n#\n# Usage:\n#   curl -fsSL https://vite.plus | bash\n#\n# Environment variables:\n#   VITE_PLUS_VERSION - Version to install (default: latest)\n#   VITE_PLUS_HOME - Installation directory (default: ~/.vite-plus)\n#   NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org)\n#   VITE_PLUS_NODE_MANAGER - Set to \"yes\" or \"no\" to skip interactive prompt (for CI/devcontainers)\n#   VITE_PLUS_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing)\n\nset -e\n\nVITE_PLUS_VERSION=\"${VITE_PLUS_VERSION:-latest}\"\nINSTALL_DIR=\"${VITE_PLUS_HOME:-$HOME/.vite-plus}\"\n# Use $HOME-relative path for shell config references (portable across sessions)\nif case \"$INSTALL_DIR\" in \"$HOME\"/*) true;; *) false;; esac; then\n  INSTALL_DIR_REF=\"\\$HOME${INSTALL_DIR#\"$HOME\"}\"\nelse\n  INSTALL_DIR_REF=\"$INSTALL_DIR\"\nfi\n# npm registry URL (strip trailing slash if present)\nNPM_REGISTRY=\"${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}\"\nNPM_REGISTRY=\"${NPM_REGISTRY%/}\"\n# Local tarball for development/testing\nLOCAL_TGZ=\"${VITE_PLUS_LOCAL_TGZ:-}\"\n# Local binary path (set by install-global-cli.ts for local dev)\nLOCAL_BINARY=\"${VITE_PLUS_LOCAL_BINARY:-}\"\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[0;33m'\nBLUE='\\033[0;34m'\nBRIGHT_BLUE='\\033[0;94m'\nBOLD='\\033[1m'\nDIM='\\033[2m'\nBOLD_BRIGHT_BLUE='\\033[1;94m'\nNC='\\033[0m' # No Color\n\ninfo() {\n  echo -e \"${BLUE}info${NC}: $1\"\n}\n\nsuccess() {\n  echo -e \"${GREEN}success${NC}: $1\"\n}\n\nwarn() {\n  echo -e \"${YELLOW}warn${NC}: $1\"\n}\n\nerror() {\n  echo -e \"${RED}error${NC}: $1\"\n  exit 1\n}\n\n# Print user-friendly error message for curl failures\n# Arguments: exit_code url\nprint_curl_error() {\n  local exit_code=\"$1\"\n  local url=\"$2\"\n\n  # Map curl exit codes to user-friendly messages\n  local error_desc\n  case $exit_code in\n    6)\n      error_desc=\"DNS resolution failed - could not resolve hostname\"\n      ;;\n    7)\n      error_desc=\"Connection refused - the server may be down or unreachable\"\n      ;;\n    28)\n      error_desc=\"Connection timed out\"\n      ;;\n    35)\n      error_desc=\"SSL/TLS connection error\"\n      ;;\n    60)\n      error_desc=\"SSL certificate verification failed\"\n      ;;\n    *)\n      error_desc=\"Network error\"\n      ;;\n  esac\n\n  echo \"\"\n  echo -e \"${RED}error${NC}: ${error_desc} (curl exit code ${exit_code})\"\n  echo \"\"\n  echo \"  This may be caused by:\"\n  echo \"    - Network connectivity issues\"\n  echo \"    - Firewall or proxy blocking the connection\"\n  echo \"    - DNS configuration problems\"\n  if [ $exit_code -eq 35 ] || [ $exit_code -eq 60 ]; then\n    echo \"    - Outdated SSL/TLS libraries\"\n  fi\n  echo \"\"\n  if [ -n \"$url\" ]; then\n    echo \"  Failed URL: $url\"\n    echo \"\"\n    echo \"  To debug, run:\"\n    echo \"    curl -v \\\"$url\\\"\"\n    echo \"\"\n  fi\n  exit 1\n}\n\n# Wrapper for curl with user-friendly error messages\n# Arguments: same as curl\n# Returns: exits with error message on failure, otherwise returns curl output\ncurl_with_error_handling() {\n  local url=\"\"\n  local args=()\n\n  # Parse arguments to find the URL (for error messages)\n  for arg in \"$@\"; do\n    case \"$arg\" in\n      http://*|https://*)\n        url=\"$arg\"\n        ;;\n    esac\n    args+=(\"$arg\")\n  done\n\n  # Run curl and capture exit code\n  set +e\n  local output exit_code\n  output=$(curl \"${args[@]}\" 2>&1)\n  exit_code=$?\n  set -e\n\n  if [ $exit_code -eq 0 ]; then\n    echo \"$output\"\n    return 0\n  fi\n\n  print_curl_error \"$exit_code\" \"$url\"\n}\n\n# Detect libc type on Linux (gnu or musl)\ndetect_libc() {\n  # Prefer positive glibc detection first.\n  # This avoids false musl detection on systems where musl is installed\n  # but the distro itself is glibc-based (common on WSL/Ubuntu).\n  if command -v getconf &> /dev/null; then\n    if getconf GNU_LIBC_VERSION > /dev/null 2>&1; then\n      echo \"gnu\"\n      return\n    fi\n  fi\n\n  # Check ldd output for musl/glibc\n  if command -v ldd &> /dev/null; then\n    ldd_out=\"$(ldd --version 2>&1 || true)\"\n    if echo \"$ldd_out\" | grep -qi musl; then\n      echo \"musl\"\n      return\n    fi\n    if echo \"$ldd_out\" | grep -qi 'gnu libc'; then\n      echo \"gnu\"\n      return\n    fi\n    if echo \"$ldd_out\" | grep -qi 'glibc'; then\n      echo \"gnu\"\n      return\n    fi\n  fi\n\n  # Final fallback: musl loader present usually indicates musl-based distro,\n  # but only check this after glibc detection to avoid false positives.\n  if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then\n    echo \"musl\"\n  else\n    echo \"gnu\"\n  fi\n}\n\n# Detect platform\ndetect_platform() {\n  local os arch\n\n  os=\"$(uname -s)\"\n  arch=\"$(uname -m)\"\n\n  case \"$os\" in\n    Darwin) os=\"darwin\" ;;\n    Linux) os=\"linux\" ;;\n    MINGW*|MSYS*|CYGWIN*) os=\"win32\" ;;\n    *) error \"Unsupported operating system: $os\" ;;\n  esac\n\n  case \"$arch\" in\n    x86_64|amd64) arch=\"x64\" ;;\n    arm64|aarch64) arch=\"arm64\" ;;\n    *) error \"Unsupported architecture: $arch\" ;;\n  esac\n\n  # For Linux, append libc type to distinguish gnu vs musl\n  if [ \"$os\" = \"linux\" ]; then\n    local libc\n    libc=$(detect_libc)\n    echo \"${os}-${arch}-${libc}\"\n  else\n    echo \"${os}-${arch}\"\n  fi\n}\n\n# Check for required commands\ncheck_requirements() {\n  local missing=()\n\n  if ! command -v curl &> /dev/null; then\n    missing+=(\"curl\")\n  fi\n\n  if ! command -v tar &> /dev/null; then\n    missing+=(\"tar\")\n  fi\n\n  if [ ${#missing[@]} -ne 0 ]; then\n    error \"Missing required commands: ${missing[*]}\"\n  fi\n}\n\n# Fetch package metadata from npm registry (cached for reuse)\n# Uses VITE_PLUS_VERSION to fetch the correct version's metadata\nPACKAGE_METADATA=\"\"\nfetch_package_metadata() {\n  if [ -z \"$PACKAGE_METADATA\" ]; then\n    local version_path metadata_url\n    if [ \"$VITE_PLUS_VERSION\" = \"latest\" ]; then\n      version_path=\"latest\"\n    else\n      version_path=\"$VITE_PLUS_VERSION\"\n    fi\n    metadata_url=\"${NPM_REGISTRY}/vite-plus/${version_path}\"\n    PACKAGE_METADATA=$(curl_with_error_handling -s \"$metadata_url\")\n    if [ -z \"$PACKAGE_METADATA\" ]; then\n      error \"Failed to fetch package metadata from: $metadata_url\"\n    fi\n    # Check for npm registry error response\n    # npm can return either {\"error\":\"...\"} or a plain JSON string like \"version not found: test\"\n    if echo \"$PACKAGE_METADATA\" | grep -q '\"error\"'; then\n      local error_msg\n      error_msg=$(echo \"$PACKAGE_METADATA\" | grep -o '\"error\":\"[^\"]*\"' | cut -d'\"' -f4)\n      error \"Failed to fetch version '${version_path}': ${error_msg:-unknown error}\"\n    fi\n    # Check if response is a plain error string (not a valid package object)\n    # Use '\"version\":' to match JSON property, not just the word \"version\"\n    if ! echo \"$PACKAGE_METADATA\" | grep -q '\"version\":'; then\n      # Remove surrounding quotes from the error message if present\n      local error_msg\n      error_msg=$(echo \"$PACKAGE_METADATA\" | sed 's/^\"//;s/\"$//')\n      error \"Failed to fetch version '${version_path}': ${error_msg:-unknown error}\"\n    fi\n  fi\n  # PACKAGE_METADATA is set as a global variable, no need to echo\n}\n\n# Get the version from package metadata\n# Sets RESOLVED_VERSION global variable\nget_version_from_metadata() {\n  # Call fetch_package_metadata to populate PACKAGE_METADATA global\n  # Don't use command substitution as it would swallow the exit from error()\n  fetch_package_metadata\n  RESOLVED_VERSION=$(echo \"$PACKAGE_METADATA\" | grep -o '\"version\":\"[^\"]*\"' | head -1 | cut -d'\"' -f4)\n  if [ -z \"$RESOLVED_VERSION\" ]; then\n    error \"Failed to extract version from package metadata\"\n  fi\n}\n\n# Get platform suffix for CLI package download\n# Sets PLATFORM_SUFFIX global variable\n# Platform format from detect_platform(): darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, win32-x64, etc.\n# CLI package format: @voidzero-dev/vite-plus-cli-darwin-arm64, @voidzero-dev/vite-plus-cli-linux-x64-gnu, etc.\nget_platform_suffix() {\n  local platform=\"$1\"\n  case \"$platform\" in\n    win32-*) PLATFORM_SUFFIX=\"${platform}-msvc\" ;;  # Windows needs -msvc suffix\n    *) PLATFORM_SUFFIX=\"$platform\" ;;               # macOS/Linux map directly\n  esac\n}\n\n# Download and extract file (silent mode - no progress bar)\ndownload_and_extract() {\n  local url=\"$1\"\n  local dest_dir=\"$2\"\n  local strip_components=\"$3\"\n  local filter=\"$4\"\n\n  # Download to temp file (silent mode)\n  local temp_file\n  temp_file=$(mktemp)\n\n  # Run curl and capture exit code for error handling\n  set +e\n  curl -sL \"$url\" -o \"$temp_file\"\n  local exit_code=$?\n  set -e\n\n  if [ $exit_code -ne 0 ]; then\n    rm -f \"$temp_file\"\n    print_curl_error \"$exit_code\" \"$url\"\n  fi\n\n  if [ -n \"$filter\" ]; then\n    tar xzf \"$temp_file\" -C \"$dest_dir\" --strip-components=\"$strip_components\" \"$filter\" 2>/dev/null || \\\n    tar xzf \"$temp_file\" -C \"$dest_dir\" --strip-components=\"$strip_components\"\n  else\n    tar xzf \"$temp_file\" -C \"$dest_dir\" --strip-components=\"$strip_components\"\n  fi\n  rm -f \"$temp_file\"\n}\n\n# Add bin to shell profile by sourcing the env file\n# Returns: 0 = path added, 1 = file not found, 2 = path already exists\nadd_bin_to_path() {\n  local shell_config=\"$1\"\n  local env_file=\"$INSTALL_DIR_REF/env\"\n  # Escape both absolute and $HOME-relative forms for grep (backward compat)\n  local abs_pattern ref_pattern\n  abs_pattern=$(printf '%s' \"$INSTALL_DIR\" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')\n  ref_pattern=$(printf '%s' \"$INSTALL_DIR_REF\" | sed 's/[.[\\*^$()+?{|]/\\\\&/g')\n\n  if [ -f \"$shell_config\" ]; then\n    if [ ! -w \"$shell_config\" ]; then\n      warn \"Cannot write to $shell_config (permission denied), skipping.\"\n      return 1\n    fi\n    if grep -q \"${abs_pattern}/env\" \"$shell_config\" 2>/dev/null || \\\n       grep -q \"${ref_pattern}/env\" \"$shell_config\" 2>/dev/null; then\n      return 2\n    fi\n    echo \"\" >> \"$shell_config\"\n    echo \"# Vite+ bin (https://viteplus.dev)\" >> \"$shell_config\"\n    echo \". \\\"$env_file\\\"\" >> \"$shell_config\"\n    return 0\n  fi\n  return 1\n}\n\n# Configure shell PATH for ~/.vite-plus/bin\n# Sets PATH_CONFIGURED and SHELL_CONFIG_UPDATED globals\nconfigure_shell_path() {\n  local bin_path=\"$INSTALL_DIR/bin\"\n  PATH_CONFIGURED=\"false\"\n  SHELL_CONFIG_UPDATED=\"\"\n\n  local result=1  # Default to failure - must explicitly set success\n  case \"$SHELL\" in\n    */zsh)\n      # Add to both .zshenv (for all shells including IDE) and .zshrc (to ensure PATH is at front)\n      # Create .zshenv if missing — it's the canonical place for PATH in zsh\n      # and is sourced by all session types (interactive, non-interactive, IDE)\n      local zsh_dir=\"${ZDOTDIR:-$HOME}\"\n      mkdir -p \"$zsh_dir\"\n      [ -f \"$zsh_dir/.zshenv\" ] || touch \"$zsh_dir/.zshenv\"\n      local zshenv_result=0 zshrc_result=0\n      add_bin_to_path \"$zsh_dir/.zshenv\" || zshenv_result=$?\n      add_bin_to_path \"$zsh_dir/.zshrc\" || zshrc_result=$?\n      # Prioritize .zshrc for user notification (easier to source)\n      if [ $zshrc_result -eq 0 ]; then\n        result=0\n        SHELL_CONFIG_UPDATED=\"$zsh_dir/.zshrc\"\n      elif [ $zshenv_result -eq 0 ]; then\n        result=0\n        SHELL_CONFIG_UPDATED=\"$zsh_dir/.zshenv\"\n      elif [ $zshenv_result -eq 2 ] || [ $zshrc_result -eq 2 ]; then\n        result=2  # already configured in at least one file\n      fi\n      ;;\n    */bash)\n      # Add to .bash_profile, .bashrc, AND .profile for maximum compatibility\n      # - .bash_profile: login shells (macOS default)\n      # - .bashrc: interactive non-login shells (Linux default)\n      # - .profile: fallback for systems without .bash_profile (Ubuntu minimal, etc.)\n      local bash_profile_result=0 bashrc_result=0 profile_result=0\n      add_bin_to_path \"$HOME/.bash_profile\" || bash_profile_result=$?\n      add_bin_to_path \"$HOME/.bashrc\" || bashrc_result=$?\n      add_bin_to_path \"$HOME/.profile\" || profile_result=$?\n      # Prioritize .bashrc for user notification (most commonly edited)\n      if [ $bashrc_result -eq 0 ]; then\n        result=0\n        SHELL_CONFIG_UPDATED=\"$HOME/.bashrc\"\n      elif [ $bash_profile_result -eq 0 ]; then\n        result=0\n        SHELL_CONFIG_UPDATED=\"$HOME/.bash_profile\"\n      elif [ $profile_result -eq 0 ]; then\n        result=0\n        SHELL_CONFIG_UPDATED=\"$HOME/.profile\"\n      elif [ $bash_profile_result -eq 2 ] || [ $bashrc_result -eq 2 ] || [ $profile_result -eq 2 ]; then\n        result=2  # already configured in at least one file\n      fi\n      ;;\n    */fish)\n      local fish_dir=\"${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d\"\n      local fish_config=\"$fish_dir/vite-plus.fish\"\n      if [ -f \"$fish_config\" ]; then\n        result=2\n      else\n        mkdir -p \"$fish_dir\"\n        echo \"# Vite+ bin (https://viteplus.dev)\" >> \"$fish_config\"\n        echo \"source \\\"$INSTALL_DIR_REF/env.fish\\\"\" >> \"$fish_config\"\n        result=0\n        SHELL_CONFIG_UPDATED=\"$fish_config\"\n      fi\n      ;;\n  esac\n\n  if [ $result -eq 0 ]; then\n    PATH_CONFIGURED=\"true\"\n  elif [ $result -eq 2 ]; then\n    PATH_CONFIGURED=\"already\"\n  fi\n  # If result is still 1, PATH_CONFIGURED remains \"false\" (set at function start)\n}\n\n# Run vp env setup --refresh, showing output only on failure\n# Arguments: vp_bin - path to the vp binary\nrefresh_shims() {\n  local vp_bin=\"$1\"\n  local setup_output\n  if ! setup_output=$(\"$vp_bin\" env setup --refresh 2>&1); then\n    warn \"Failed to refresh shims:\"\n    echo \"$setup_output\" >&2\n  fi\n}\n\n# Setup Node.js version manager (node/npm/npx shims)\n# Sets NODE_MANAGER_ENABLED global\n# Arguments: bin_dir - path to the version's bin directory containing vp\nsetup_node_manager() {\n  local bin_dir=\"$1\"\n  local bin_path=\"$INSTALL_DIR/bin\"\n  NODE_MANAGER_ENABLED=\"false\"\n\n  # Resolve vp binary name (vp on Unix, vp.exe on Windows)\n  local vp_bin=\"$bin_dir/vp\"\n  if [ -f \"$bin_dir/vp.exe\" ]; then\n    vp_bin=\"$bin_dir/vp.exe\"\n  fi\n\n  # Explicit override via environment variable\n  if [ \"$VITE_PLUS_NODE_MANAGER\" = \"yes\" ]; then\n    refresh_shims \"$vp_bin\"\n    NODE_MANAGER_ENABLED=\"true\"\n    return 0\n  elif [ \"$VITE_PLUS_NODE_MANAGER\" = \"no\" ]; then\n    NODE_MANAGER_ENABLED=\"false\"\n    return 0\n  fi\n\n  # Check if Vite+ is already managing Node.js (bin/node or bin/node.exe exists)\n  if [ -e \"$bin_path/node\" ] || [ -e \"$bin_path/node.exe\" ]; then\n    refresh_shims \"$vp_bin\"\n    NODE_MANAGER_ENABLED=\"already\"\n    return 0\n  fi\n\n  # Auto-enable on CI or devcontainer environments\n  # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.)\n  # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces)\n  # REMOTE_CONTAINERS: set by VS Code Dev Containers extension\n  # DEVPOD: set by DevPod (https://devpod.sh)\n  if [ -n \"$CI\" ] || [ -n \"$CODESPACES\" ] || [ -n \"$REMOTE_CONTAINERS\" ] || [ -n \"$DEVPOD\" ]; then\n    refresh_shims \"$vp_bin\"\n    NODE_MANAGER_ENABLED=\"true\"\n    return 0\n  fi\n\n  # Check if node is available on the system\n  local node_available=\"false\"\n  if command -v node &> /dev/null; then\n    node_available=\"true\"\n  fi\n\n  # Auto-enable if no node available on system\n  if [ \"$node_available\" = \"false\" ]; then\n    refresh_shims \"$vp_bin\"\n    NODE_MANAGER_ENABLED=\"true\"\n    return 0\n  fi\n\n  # Prompt user in interactive mode\n  if [ -e /dev/tty ] && [ -t 1 ]; then\n    echo \"\"\n    echo \"Would you want Vite+ to manage Node.js versions?\"\n    echo -n \"Press Enter to accept (Y/n): \"\n    read -r response < /dev/tty\n\n    if [ -z \"$response\" ] || [ \"$response\" = \"y\" ] || [ \"$response\" = \"Y\" ]; then\n      refresh_shims \"$vp_bin\"\n      NODE_MANAGER_ENABLED=\"true\"\n    fi\n  fi\n}\n\n# Cleanup old versions, keeping only the most recent ones\ncleanup_old_versions() {\n  local max_versions=5\n  local versions=()\n\n  # List version directories (semver format like 0.1.0, 1.2.3-beta.1, 0.0.0-f48af939.20260205-0533)\n  # This excludes 'current' symlink and non-semver directories like 'local-dev'\n  local semver_regex='^[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9._-]+)?$'\n  for dir in \"$INSTALL_DIR\"/*/; do\n    local name\n    name=$(basename \"$dir\")\n    if [ -d \"$dir\" ] && [[ \"$name\" =~ $semver_regex ]]; then\n      versions+=(\"$dir\")\n    fi\n  done\n\n  local count=${#versions[@]}\n  if [ \"$count\" -le \"$max_versions\" ]; then\n    return 0\n  fi\n\n  # Sort by creation time (oldest first) and delete excess\n  local sorted_versions\n  if [[ \"$OSTYPE\" == \"darwin\"* ]]; then\n    # macOS: use stat -f %B for birth time\n    sorted_versions=$(for v in \"${versions[@]}\"; do\n      echo \"$(stat -f %B \"$v\") $v\"\n    done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-)\n  else\n    # Linux: use stat -c %W for birth time, fallback to %Y (mtime)\n    sorted_versions=$(for v in \"${versions[@]}\"; do\n      local btime\n      btime=$(stat -c %W \"$v\" 2>/dev/null)\n      if [ \"$btime\" = \"0\" ] || [ -z \"$btime\" ]; then\n        btime=$(stat -c %Y \"$v\")\n      fi\n      echo \"$btime $v\"\n    done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-)\n  fi\n\n  # Delete oldest versions (silently)\n  for old_version in $sorted_versions; do\n    rm -rf \"$old_version\"\n  done\n}\n\nmain() {\n  echo \"\"\n  echo -e \"Setting up VITE+...\"\n\n  check_requirements\n\n  local platform\n  platform=$(detect_platform)\n\n  # Local development mode: use local tgz\n  if [ -n \"$LOCAL_TGZ\" ]; then\n    # Validate local tgz\n    if [ ! -f \"$LOCAL_TGZ\" ]; then\n      error \"Local tarball not found: $LOCAL_TGZ\"\n    fi\n    # Use version as-is (default to \"local-dev\")\n    if [ \"$VITE_PLUS_VERSION\" = \"latest\" ] || [ \"$VITE_PLUS_VERSION\" = \"test\" ]; then\n      VITE_PLUS_VERSION=\"local-dev\"\n    fi\n  else\n    # Fetch package metadata and resolve version from npm\n    get_version_from_metadata\n    VITE_PLUS_VERSION=\"$RESOLVED_VERSION\"\n  fi\n\n  # Set up version-specific directories\n  VERSION_DIR=\"$INSTALL_DIR/$VITE_PLUS_VERSION\"\n  BIN_DIR=\"$VERSION_DIR/bin\"\n  CURRENT_LINK=\"$INSTALL_DIR/current\"\n\n  local binary_name=\"vp\"\n  if [[ \"$platform\" == win32* ]]; then\n    binary_name=\"vp.exe\"\n  fi\n\n  # Create bin directory\n  mkdir -p \"$BIN_DIR\"\n\n  if [ -n \"$LOCAL_TGZ\" ]; then\n    # Local development mode: only need the binary\n    info \"Using local tarball: $LOCAL_TGZ\"\n\n    # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts)\n    if [ -n \"$LOCAL_BINARY\" ]; then\n      cp \"$LOCAL_BINARY\" \"$BIN_DIR/$binary_name\"\n      # On Windows, also copy the trampoline shim binary if available\n      if [[ \"$platform\" == win32* ]]; then\n        local shim_src\n        shim_src=\"$(dirname \"$LOCAL_BINARY\")/vp-shim.exe\"\n        if [ -f \"$shim_src\" ]; then\n          cp \"$shim_src\" \"$BIN_DIR/vp-shim.exe\"\n        fi\n      fi\n    else\n      error \"VITE_PLUS_LOCAL_BINARY must be set when using VITE_PLUS_LOCAL_TGZ\"\n    fi\n    chmod +x \"$BIN_DIR/$binary_name\"\n  else\n    # Download from npm registry — extract only the vp binary from CLI platform package\n    get_platform_suffix \"$platform\"\n    local package_name=\"@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}\"\n    local platform_url=\"${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VITE_PLUS_VERSION}.tgz\"\n\n    # Create temp directory for extraction\n    local platform_temp_dir\n    platform_temp_dir=$(mktemp -d)\n    download_and_extract \"$platform_url\" \"$platform_temp_dir\" 1\n\n    # Copy binary to BIN_DIR\n    cp \"$platform_temp_dir/$binary_name\" \"$BIN_DIR/\"\n    chmod +x \"$BIN_DIR/$binary_name\"\n    # On Windows, also copy the trampoline shim binary if present in the package\n    if [[ \"$platform\" == win32* ]] && [ -f \"$platform_temp_dir/vp-shim.exe\" ]; then\n      cp \"$platform_temp_dir/vp-shim.exe\" \"$BIN_DIR/\"\n    fi\n    rm -rf \"$platform_temp_dir\"\n  fi\n\n  # Generate wrapper package.json that declares vite-plus as a dependency.\n  # npm will install vite-plus and all transitive deps via `vp install`.\n  cat > \"$VERSION_DIR/package.json\" <<WRAPPER_EOF\n{\n  \"name\": \"vp-global\",\n  \"version\": \"$VITE_PLUS_VERSION\",\n  \"private\": true,\n  \"dependencies\": {\n    \"vite-plus\": \"$VITE_PLUS_VERSION\"\n  }\n}\nWRAPPER_EOF\n\n  # Isolate from user's global package manager config that may block\n  # installing recently-published packages (e.g. pnpm's minimumReleaseAge,\n  # npm's min-release-age) by creating a local .npmrc in the version directory.\n  cat > \"$VERSION_DIR/.npmrc\" <<NPMRC_EOF\nminimum-release-age=0\nmin-release-age=0\nNPMRC_EOF\n\n  # Install production dependencies (skip if VITE_PLUS_SKIP_DEPS_INSTALL is set,\n  # e.g. during local dev where install-global-cli.ts handles deps separately)\n  if [ -z \"${VITE_PLUS_SKIP_DEPS_INSTALL:-}\" ]; then\n    local install_log=\"$VERSION_DIR/install.log\"\n    local vp_install_bin=\"$BIN_DIR/vp\"\n    if [ -f \"$BIN_DIR/vp.exe\" ]; then\n      vp_install_bin=\"$BIN_DIR/vp.exe\"\n    fi\n    if ! (cd \"$VERSION_DIR\" && CI=true \"$vp_install_bin\" install --silent > \"$install_log\" 2>&1); then\n      error \"Failed to install dependencies. See log for details: $install_log\"\n      exit 1\n    fi\n  fi\n\n  # Create/update current symlink (use relative path for portability)\n  ln -sfn \"$VITE_PLUS_VERSION\" \"$CURRENT_LINK\"\n\n  # Create bin directory and vp entrypoint (always done)\n  mkdir -p \"$INSTALL_DIR/bin\"\n  if [[ \"$platform\" == win32* ]]; then\n    # Windows: copy trampoline as vp.exe (matching install.ps1)\n    if [ -f \"$INSTALL_DIR/current/bin/vp-shim.exe\" ]; then\n      cp \"$INSTALL_DIR/current/bin/vp-shim.exe\" \"$INSTALL_DIR/bin/vp.exe\"\n    fi\n  else\n    # Unix: symlink to current/bin/vp\n    ln -sf \"../current/bin/vp\" \"$INSTALL_DIR/bin/vp\"\n  fi\n\n  # Cleanup old versions\n  cleanup_old_versions\n\n  # Create env files with PATH guard (prevents duplicate PATH entries)\n  # Use current/bin/vp directly (the real binary) instead of bin/vp (trampoline)\n  # to avoid the self-overwrite issue on Windows during --refresh\n  local vp_bin=\"$INSTALL_DIR/current/bin/vp\"\n  if [[ \"$platform\" == win32* ]]; then\n    vp_bin=\"$INSTALL_DIR/current/bin/vp.exe\"\n  fi\n  \"$vp_bin\" env setup --env-only > /dev/null\n\n  # Configure shell PATH (always attempted)\n  configure_shell_path\n\n  # Setup Node.js version manager (shims) - separate component\n  setup_node_manager \"$BIN_DIR\"\n\n  # Use ~ shorthand if install dir is under HOME, otherwise show full path\n  local display_dir=\"${INSTALL_DIR/#$HOME/~}\"\n  local display_location=\"${display_dir}/bin\"\n\n  # Print success message\n  echo \"\"\n  echo -e \"${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!\"\n  echo \"\"\n  echo \"  The Unified Toolchain for the Web.\"\n  echo \"\"\n  echo -e \"  ${BOLD}Get started:${NC}\"\n  echo -e \"    ${BRIGHT_BLUE}vp create${NC}       Create a new project\"\n  echo -e \"    ${BRIGHT_BLUE}vp env${NC}          Manage Node.js versions\"\n  echo -e \"    ${BRIGHT_BLUE}vp install${NC}      Install dependencies\"\n  echo -e \"    ${BRIGHT_BLUE}vp migrate${NC}      Migrate to Vite+\"\n\n  if [ \"$NODE_MANAGER_ENABLED\" = \"true\" ] || [ \"$NODE_MANAGER_ENABLED\" = \"already\" ]; then\n    echo \"\"\n    echo -e \"  Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}.\"\n    echo -e \"  Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out.\"\n  fi\n\n  echo \"\"\n  echo -e \"  Run ${BRIGHT_BLUE}vp help${NC} to see available commands.\"\n\n  # Show restart note if PATH was added to shell config\n  if [ \"$PATH_CONFIGURED\" = \"true\" ] && [ -n \"$SHELL_CONFIG_UPDATED\" ]; then\n    local display_config\n    if [ \"${SHELL_CONFIG_UPDATED#\"$HOME\"}\" != \"$SHELL_CONFIG_UPDATED\" ]; then\n      display_config=\"~${SHELL_CONFIG_UPDATED#\"$HOME\"}\"\n    else\n      display_config=\"$SHELL_CONFIG_UPDATED\"\n    fi\n    echo \"\"\n    if [ \"${display_config#\"~\"}\" != \"$display_config\" ]; then\n      echo \"  Note: Run \\`source $display_config\\` or restart your terminal.\"\n    else\n      echo \"  Note: Run \\`source \\\"$display_config\\\"\\` or restart your terminal.\"\n    fi\n  fi\n\n  # Show warning if PATH could not be automatically configured\n  if [ \"$PATH_CONFIGURED\" = \"false\" ]; then\n    echo \"\"\n    echo -e \"  ${YELLOW}note${NC}: Could not automatically add vp to your PATH.\"\n    echo \"\"\n    echo -e \"  vp was installed to: ${BOLD}${display_location}${NC}\"\n    echo \"\"\n    echo \"  To use vp, add this line to your shell config file:\"\n    echo \"\"\n    echo \"    . \\\"$INSTALL_DIR_REF/env\\\"\"\n    echo \"\"\n    echo \"  Common config files:\"\n    echo \"    - Bash: ~/.bashrc or ~/.bash_profile\"\n    echo \"    - Zsh:  ~/.zshrc\"\n    echo \"    - Fish: source \\\"$INSTALL_DIR_REF/env.fish\\\" in ~/.config/fish/config.fish\"\n    echo \"\"\n    echo \"  Or run vp directly:\"\n    echo \"\"\n    echo -e \"    ${display_location}/vp\"\n  fi\n\n  echo \"\"\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "packages/cli/package.json",
    "content": "{\n  \"name\": \"vite-plus\",\n  \"version\": \"0.0.0\",\n  \"description\": \"The Unified Toolchain for the Web\",\n  \"homepage\": \"https://viteplus.dev/guide\",\n  \"bugs\": {\n    \"url\": \"https://github.com/voidzero-dev/vite-plus/issues\"\n  },\n  \"license\": \"MIT\",\n  \"author\": \"VoidZero Inc.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/voidzero-dev/vite-plus.git\",\n    \"directory\": \"packages/cli\"\n  },\n  \"bin\": {\n    \"oxfmt\": \"./bin/oxfmt\",\n    \"oxlint\": \"./bin/oxlint\",\n    \"vp\": \"./bin/vp\"\n  },\n  \"files\": [\n    \"AGENTS.md\",\n    \"bin\",\n    \"dist\",\n    \"binding/*.node\",\n    \"binding/index.cjs\",\n    \"binding/index.d.cts\",\n    \"binding/index.d.ts\",\n    \"binding/index.js\",\n    \"dist/test\",\n    \"rules\",\n    \"skills\",\n    \"templates\"\n  ],\n  \"type\": \"module\",\n  \"sideEffects\": false,\n  \"main\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.cjs\"\n    },\n    \"./client\": {\n      \"types\": \"./dist/client.d.ts\"\n    },\n    \"./module-runner\": {\n      \"types\": \"./dist/module-runner.d.ts\",\n      \"default\": \"./dist/module-runner.js\"\n    },\n    \"./internal\": {\n      \"types\": \"./dist/internal.d.ts\",\n      \"default\": \"./dist/internal.js\"\n    },\n    \"./dist/client/*\": \"./dist/client/*\",\n    \"./types/internal/*\": null,\n    \"./types/*\": {\n      \"types\": \"./dist/types/*\"\n    },\n    \"./bin\": {\n      \"import\": \"./dist/bin.js\"\n    },\n    \"./binding\": {\n      \"types\": \"./binding/index.d.cts\",\n      \"import\": \"./binding/index.cjs\",\n      \"require\": \"./binding/index.cjs\"\n    },\n    \"./lint\": {\n      \"types\": \"./dist/lint.d.ts\",\n      \"import\": \"./dist/lint.js\"\n    },\n    \"./package.json\": \"./package.json\",\n    \"./pack\": {\n      \"types\": \"./dist/pack.d.ts\",\n      \"import\": \"./dist/pack.js\"\n    },\n    \"./test\": {\n      \"import\": {\n        \"types\": \"./dist/test/index.d.ts\",\n        \"node\": \"./dist/test/index.js\",\n        \"default\": \"./dist/test/index.js\"\n      },\n      \"require\": {\n        \"types\": \"./dist/test/index.d.cts\",\n        \"default\": \"./dist/test/index.cjs\"\n      }\n    },\n    \"./test/browser\": {\n      \"types\": \"./dist/test/browser.d.ts\",\n      \"default\": \"./dist/test/browser.js\"\n    },\n    \"./test/optional-types.js\": {\n      \"types\": \"./dist/test/optional-types.js.d.ts\"\n    },\n    \"./test/optional-runtime-types.js\": {\n      \"types\": \"./dist/test/optional-runtime-types.js.d.ts\"\n    },\n    \"./test/globals\": {\n      \"types\": \"./dist/test/globals.d.ts\"\n    },\n    \"./test/jsdom\": {\n      \"types\": \"./dist/test/jsdom.d.ts\"\n    },\n    \"./test/importMeta\": {\n      \"types\": \"./dist/test/importMeta.d.ts\"\n    },\n    \"./test/import-meta\": {\n      \"types\": \"./dist/test/import-meta.d.ts\"\n    },\n    \"./test/node\": {\n      \"types\": \"./dist/test/node.d.ts\",\n      \"default\": \"./dist/test/node.js\"\n    },\n    \"./test/internal/browser\": {\n      \"types\": \"./dist/test/internal/browser.d.ts\",\n      \"default\": \"./dist/test/internal/browser.js\"\n    },\n    \"./test/runners\": {\n      \"types\": \"./dist/test/runners.d.ts\",\n      \"default\": \"./dist/test/runners.js\"\n    },\n    \"./test/suite\": {\n      \"types\": \"./dist/test/suite.d.ts\",\n      \"default\": \"./dist/test/suite.js\"\n    },\n    \"./test/environments\": {\n      \"types\": \"./dist/test/environments.d.ts\",\n      \"default\": \"./dist/test/environments.js\"\n    },\n    \"./test/config\": {\n      \"types\": \"./dist/test/config.d.ts\",\n      \"default\": \"./dist/test/config.js\",\n      \"require\": \"./dist/test/config.cjs\"\n    },\n    \"./test/coverage\": {\n      \"types\": \"./dist/test/coverage.d.ts\",\n      \"default\": \"./dist/test/coverage.js\"\n    },\n    \"./test/reporters\": {\n      \"types\": \"./dist/test/reporters.d.ts\",\n      \"default\": \"./dist/test/reporters.js\"\n    },\n    \"./test/snapshot\": {\n      \"types\": \"./dist/test/snapshot.d.ts\",\n      \"default\": \"./dist/test/snapshot.js\"\n    },\n    \"./test/runtime\": {\n      \"types\": \"./dist/test/runtime.d.ts\",\n      \"default\": \"./dist/test/runtime.js\"\n    },\n    \"./test/worker\": {\n      \"types\": \"./dist/test/worker.d.ts\",\n      \"default\": \"./dist/test/worker.js\"\n    },\n    \"./test/browser-compat\": {\n      \"default\": \"./dist/test/browser-compat.js\"\n    },\n    \"./test/client\": {\n      \"default\": \"./dist/test/client.js\"\n    },\n    \"./test/context\": {\n      \"types\": \"./dist/test/context.d.ts\",\n      \"default\": \"./dist/test/context.js\"\n    },\n    \"./test/browser/context\": {\n      \"types\": \"./dist/test/browser/context.d.ts\",\n      \"default\": \"./dist/test/browser/context.js\"\n    },\n    \"./test/locators\": {\n      \"default\": \"./dist/test/locators.js\"\n    },\n    \"./test/matchers\": {\n      \"default\": \"./dist/test/matchers.js\"\n    },\n    \"./test/utils\": {\n      \"default\": \"./dist/test/utils.js\"\n    },\n    \"./test/browser-playwright\": {\n      \"types\": \"./dist/test/browser-playwright.d.ts\",\n      \"default\": \"./dist/test/browser-playwright.js\"\n    },\n    \"./test/browser-webdriverio\": {\n      \"types\": \"./dist/test/browser-webdriverio.d.ts\",\n      \"default\": \"./dist/test/browser-webdriverio.js\"\n    },\n    \"./test/browser-preview\": {\n      \"types\": \"./dist/test/browser-preview.d.ts\",\n      \"default\": \"./dist/test/browser-preview.js\"\n    },\n    \"./test/browser/providers/playwright\": {\n      \"types\": \"./dist/test/browser/providers/playwright.d.ts\",\n      \"default\": \"./dist/test/browser/providers/playwright.js\"\n    },\n    \"./test/browser/providers/webdriverio\": {\n      \"types\": \"./dist/test/browser/providers/webdriverio.d.ts\",\n      \"default\": \"./dist/test/browser/providers/webdriverio.js\"\n    },\n    \"./test/browser/providers/preview\": {\n      \"types\": \"./dist/test/browser/providers/preview.d.ts\",\n      \"default\": \"./dist/test/browser/providers/preview.js\"\n    },\n    \"./test/plugins/runner\": {\n      \"default\": \"./dist/test/plugins/runner.js\"\n    },\n    \"./test/plugins/runner-utils\": {\n      \"default\": \"./dist/test/plugins/runner-utils.js\"\n    },\n    \"./test/plugins/runner-types\": {\n      \"default\": \"./dist/test/plugins/runner-types.js\"\n    },\n    \"./test/plugins/utils\": {\n      \"default\": \"./dist/test/plugins/utils.js\"\n    },\n    \"./test/plugins/utils-source-map\": {\n      \"default\": \"./dist/test/plugins/utils-source-map.js\"\n    },\n    \"./test/plugins/utils-source-map-node\": {\n      \"default\": \"./dist/test/plugins/utils-source-map-node.js\"\n    },\n    \"./test/plugins/utils-error\": {\n      \"default\": \"./dist/test/plugins/utils-error.js\"\n    },\n    \"./test/plugins/utils-helpers\": {\n      \"default\": \"./dist/test/plugins/utils-helpers.js\"\n    },\n    \"./test/plugins/utils-display\": {\n      \"default\": \"./dist/test/plugins/utils-display.js\"\n    },\n    \"./test/plugins/utils-timers\": {\n      \"default\": \"./dist/test/plugins/utils-timers.js\"\n    },\n    \"./test/plugins/utils-highlight\": {\n      \"default\": \"./dist/test/plugins/utils-highlight.js\"\n    },\n    \"./test/plugins/utils-offset\": {\n      \"default\": \"./dist/test/plugins/utils-offset.js\"\n    },\n    \"./test/plugins/utils-resolver\": {\n      \"default\": \"./dist/test/plugins/utils-resolver.js\"\n    },\n    \"./test/plugins/utils-serialize\": {\n      \"default\": \"./dist/test/plugins/utils-serialize.js\"\n    },\n    \"./test/plugins/utils-constants\": {\n      \"default\": \"./dist/test/plugins/utils-constants.js\"\n    },\n    \"./test/plugins/utils-diff\": {\n      \"default\": \"./dist/test/plugins/utils-diff.js\"\n    },\n    \"./test/plugins/spy\": {\n      \"default\": \"./dist/test/plugins/spy.js\"\n    },\n    \"./test/plugins/expect\": {\n      \"default\": \"./dist/test/plugins/expect.js\"\n    },\n    \"./test/plugins/snapshot\": {\n      \"default\": \"./dist/test/plugins/snapshot.js\"\n    },\n    \"./test/plugins/snapshot-environment\": {\n      \"default\": \"./dist/test/plugins/snapshot-environment.js\"\n    },\n    \"./test/plugins/snapshot-manager\": {\n      \"default\": \"./dist/test/plugins/snapshot-manager.js\"\n    },\n    \"./test/plugins/mocker\": {\n      \"default\": \"./dist/test/plugins/mocker.js\"\n    },\n    \"./test/plugins/mocker-node\": {\n      \"default\": \"./dist/test/plugins/mocker-node.js\"\n    },\n    \"./test/plugins/mocker-browser\": {\n      \"default\": \"./dist/test/plugins/mocker-browser.js\"\n    },\n    \"./test/plugins/mocker-redirect\": {\n      \"default\": \"./dist/test/plugins/mocker-redirect.js\"\n    },\n    \"./test/plugins/mocker-transforms\": {\n      \"default\": \"./dist/test/plugins/mocker-transforms.js\"\n    },\n    \"./test/plugins/mocker-automock\": {\n      \"default\": \"./dist/test/plugins/mocker-automock.js\"\n    },\n    \"./test/plugins/mocker-register\": {\n      \"default\": \"./dist/test/plugins/mocker-register.js\"\n    },\n    \"./test/plugins/pretty-format\": {\n      \"default\": \"./dist/test/plugins/pretty-format.js\"\n    },\n    \"./test/plugins/browser\": {\n      \"default\": \"./dist/test/plugins/browser.js\"\n    },\n    \"./test/plugins/browser-context\": {\n      \"default\": \"./dist/test/plugins/browser-context.js\"\n    },\n    \"./test/plugins/browser-client\": {\n      \"default\": \"./dist/test/plugins/browser-client.js\"\n    },\n    \"./test/plugins/browser-locators\": {\n      \"default\": \"./dist/test/plugins/browser-locators.js\"\n    },\n    \"./test/plugins/browser-playwright\": {\n      \"default\": \"./dist/test/plugins/browser-playwright.js\"\n    },\n    \"./test/plugins/browser-webdriverio\": {\n      \"default\": \"./dist/test/plugins/browser-webdriverio.js\"\n    },\n    \"./test/plugins/browser-preview\": {\n      \"default\": \"./dist/test/plugins/browser-preview.js\"\n    }\n  },\n  \"scripts\": {\n    \"build\": \"oxnode -C dev ./build.ts\",\n    \"build-ts\": \"oxnode -C dev ./build.ts --skip-native\",\n    \"build-native\": \"oxnode -C dev ./build.ts --skip-ts\",\n    \"snap-test\": \"pnpm snap-test-local && pnpm snap-test-global\",\n    \"snap-test-local\": \"tool snap-test\",\n    \"snap-test-global\": \"tool snap-test --dir snap-tests-global --bin-dir ~/.vite-plus/bin\",\n    \"publish-native\": \"node ./publish-native-addons.ts\",\n    \"test\": \"vitest run\"\n  },\n  \"dependencies\": {\n    \"@oxc-project/types\": \"catalog:\",\n    \"@voidzero-dev/vite-plus-core\": \"workspace:*\",\n    \"@voidzero-dev/vite-plus-test\": \"workspace:*\",\n    \"cac\": \"catalog:\",\n    \"cross-spawn\": \"catalog:\",\n    \"oxfmt\": \"catalog:\",\n    \"oxlint\": \"catalog:\",\n    \"oxlint-tsgolint\": \"catalog:\",\n    \"picocolors\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"@napi-rs/cli\": \"catalog:\",\n    \"@nkzw/safe-word-list\": \"catalog:\",\n    \"@oxc-node/core\": \"^0.0.32\",\n    \"@types/cross-spawn\": \"catalog:\",\n    \"@types/semver\": \"catalog:\",\n    \"@types/validate-npm-package-name\": \"catalog:\",\n    \"@voidzero-dev/vite-plus-prompts\": \"workspace:*\",\n    \"@voidzero-dev/vite-plus-tools\": \"workspace:\",\n    \"detect-indent\": \"catalog:\",\n    \"detect-newline\": \"catalog:\",\n    \"glob\": \"catalog:\",\n    \"jsonc-parser\": \"catalog:\",\n    \"lint-staged\": \"catalog:\",\n    \"minimatch\": \"catalog:\",\n    \"mri\": \"catalog:\",\n    \"rolldown\": \"workspace:*\",\n    \"rolldown-plugin-dts\": \"catalog:\",\n    \"semver\": \"catalog:\",\n    \"tsdown\": \"catalog:\",\n    \"validate-npm-package-name\": \"catalog:\",\n    \"vite\": \"workspace:*\",\n    \"yaml\": \"catalog:\"\n  },\n  \"napi\": {\n    \"binaryName\": \"vite-plus\",\n    \"packageName\": \"@voidzero-dev/vite-plus\",\n    \"targets\": [\n      \"aarch64-apple-darwin\",\n      \"x86_64-apple-darwin\",\n      \"aarch64-unknown-linux-gnu\",\n      \"x86_64-unknown-linux-gnu\",\n      \"x86_64-pc-windows-msvc\",\n      \"aarch64-pc-windows-msvc\"\n    ]\n  },\n  \"engines\": {\n    \"node\": \"^20.19.0 || >=22.12.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/publish-native-addons.ts",
    "content": "import { execSync } from 'node:child_process';\nimport {\n  copyFileSync,\n  existsSync,\n  chmodSync,\n  mkdirSync,\n  readFileSync,\n  rmSync,\n  writeFileSync,\n} from 'node:fs';\nimport { readdir } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport { NapiCli } from '@napi-rs/cli';\n\nconst cli = new NapiCli();\n\nconst currentDir = dirname(fileURLToPath(import.meta.url));\nconst repoRoot = join(currentDir, '..', '..');\n\n// Create npm directories for NAPI bindings\nawait cli.createNpmDirs({\n  cwd: currentDir,\n  packageJsonPath: './package.json',\n});\n\n// Copy NAPI artifacts\nawait cli.artifacts({\n  cwd: currentDir,\n  packageJsonPath: './package.json',\n});\n\n// Pre-publish (Update package.json and copy addons into per platform packages)\nawait cli.prePublish({\n  cwd: currentDir,\n  packageJsonPath: './package.json',\n  tagStyle: 'npm',\n  ghRelease: false,\n  skipOptionalPublish: true,\n});\n\n// Mapping from npm platform directory names to Rust target triples\nconst RUST_TARGETS: Record<string, string> = {\n  'darwin-arm64': 'aarch64-apple-darwin',\n  'darwin-x64': 'x86_64-apple-darwin',\n  'linux-arm64-gnu': 'aarch64-unknown-linux-gnu',\n  'linux-x64-gnu': 'x86_64-unknown-linux-gnu',\n  'win32-arm64-msvc': 'aarch64-pc-windows-msvc',\n  'win32-x64-msvc': 'x86_64-pc-windows-msvc',\n};\nconst npmDir = join(currentDir, 'npm');\nconst platformDirs = await readdir(npmDir);\n\n// Publish each NAPI platform package (without vp binary)\nconst npmTag = process.env.NPM_TAG || 'latest';\nfor (const file of platformDirs) {\n  execSync(`npm publish --tag ${npmTag} --access public`, {\n    cwd: join(currentDir, 'npm', file),\n    env: process.env,\n    stdio: 'inherit',\n  });\n}\n\n// Platform metadata for CLI packages\nconst PLATFORM_META: Record<string, { os: string; cpu: string }> = {\n  'darwin-arm64': { os: 'darwin', cpu: 'arm64' },\n  'darwin-x64': { os: 'darwin', cpu: 'x64' },\n  'linux-arm64-gnu': { os: 'linux', cpu: 'arm64' },\n  'linux-x64-gnu': { os: 'linux', cpu: 'x64' },\n  'win32-arm64-msvc': { os: 'win32', cpu: 'arm64' },\n  'win32-x64-msvc': { os: 'win32', cpu: 'x64' },\n};\n\n// Read version from packages/cli/package.json for lockstep versioning\nconst cliPackageJson = JSON.parse(readFileSync(join(currentDir, 'package.json'), 'utf-8'));\nconst cliVersion = cliPackageJson.version;\n\n// Create and publish separate @voidzero-dev/vite-plus-cli-{platform} packages\nconst cliNpmDir = join(currentDir, 'cli-npm');\nfor (const [platform, rustTarget] of Object.entries(RUST_TARGETS)) {\n  const meta = PLATFORM_META[platform];\n  if (!meta) {\n    // eslint-disable-next-line no-console\n    console.log(`Skipping CLI package for ${platform}: no platform metadata`);\n    continue;\n  }\n\n  const isWindows = platform.startsWith('win32');\n  const binaryName = isWindows ? 'vp.exe' : 'vp';\n  const rustBinarySource = join(repoRoot, 'target', rustTarget, 'release', binaryName);\n\n  if (!existsSync(rustBinarySource)) {\n    // eslint-disable-next-line no-console\n    console.warn(\n      `Warning: Rust binary not found at ${rustBinarySource}, skipping CLI package for ${platform}`,\n    );\n    continue;\n  }\n\n  // Create temp directory for CLI package\n  const platformCliDir = join(cliNpmDir, platform);\n  mkdirSync(platformCliDir, { recursive: true });\n\n  // Copy binary\n  copyFileSync(rustBinarySource, join(platformCliDir, binaryName));\n  if (!isWindows) {\n    chmodSync(join(platformCliDir, binaryName), 0o755);\n  }\n\n  // Copy trampoline shim binary for Windows (required)\n  // The trampoline is a small exe that replaces .cmd wrappers to avoid\n  // \"Terminate batch job (Y/N)?\" on Ctrl+C (see issue #835)\n  const shimName = 'vp-shim.exe';\n  const files = [binaryName];\n  if (isWindows) {\n    const shimSource = join(repoRoot, 'target', rustTarget, 'release', shimName);\n    if (!existsSync(shimSource)) {\n      console.error(\n        `Error: ${shimName} not found at ${shimSource}. Run \"cargo build -p vite_trampoline --release --target ${rustTarget}\" first.`,\n      );\n      process.exit(1);\n    }\n    copyFileSync(shimSource, join(platformCliDir, shimName));\n    files.push(shimName);\n  }\n\n  // Generate package.json\n  const cliPackage = {\n    name: `@voidzero-dev/vite-plus-cli-${platform}`,\n    version: cliVersion,\n    os: [meta.os],\n    cpu: [meta.cpu],\n    files,\n    description: `Vite+ CLI binary for ${platform}`,\n    repository: cliPackageJson.repository,\n  };\n  writeFileSync(join(platformCliDir, 'package.json'), JSON.stringify(cliPackage, null, 2) + '\\n');\n\n  // Publish CLI package\n  execSync(`npm publish --tag ${npmTag} --access public`, {\n    cwd: platformCliDir,\n    env: process.env,\n    stdio: 'inherit',\n  });\n\n  // eslint-disable-next-line no-console\n  console.log(`Published CLI package: @voidzero-dev/vite-plus-cli-${platform}@${cliVersion}`);\n}\n\n// Clean up cli-npm directory\nrmSync(cliNpmDir, { recursive: true, force: true });\n"
  },
  {
    "path": "packages/cli/rolldown.config.ts",
    "content": "import { builtinModules } from 'node:module';\n\nimport { defineConfig } from 'rolldown';\n\n// Node.js built-in modules (both bare and node:-prefixed).\n// Needed because lint-staged's CJS dependencies use require('util') etc.\nconst nodeBuiltins = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));\n\nexport default defineConfig({\n  input: {\n    create: './src/create/bin.ts',\n    migrate: './src/migration/bin.ts',\n    version: './src/version.ts',\n    config: './src/config/bin.ts',\n    mcp: './src/mcp/bin.ts',\n    staged: './src/staged/bin.ts',\n  },\n  treeshake: false,\n  external(source) {\n    if (nodeBuiltins.has(source)) {\n      return true;\n    }\n    if (\n      source === 'cross-spawn' ||\n      source === 'picocolors' ||\n      source === '@voidzero-dev/vite-plus-core'\n    ) {\n      return true;\n    }\n    if (source === '../../binding/index.js' || source === '../binding/index.js') {\n      return true;\n    }\n    return false;\n  },\n  plugins: [\n    {\n      name: 'fix-binding-path',\n      // Rewrite the binding import path for the output directory.\n      // Source files import from ../../binding/index.js (relative to src/*/).\n      // Output is in dist/global/, so the correct path is ../../binding/index.js (two dirs up).\n      // Rolldown normalizes it to ../binding/index.js which is wrong.\n      renderChunk(code) {\n        if (code.includes('../binding/index.js')) {\n          return { code: code.replaceAll('../binding/index.js', '../../binding/index.js') };\n        }\n        return null;\n      },\n    },\n    {\n      name: 'inject-cjs-require',\n      // Inject createRequire into chunks that use rolldown's __require CJS shim.\n      // lint-staged's CJS dependencies use require('util') etc., which fails in ESM.\n      // By providing a real `require` via createRequire, the shim works correctly.\n      renderChunk(code) {\n        if (code.includes('typeof require')) {\n          const injection = `import { createRequire as __createRequire } from 'node:module';\\nconst require = __createRequire(import.meta.url);\\n`;\n          return { code: injection + code };\n        }\n        return null;\n      },\n    },\n  ],\n  output: {\n    format: 'esm',\n    dir: './dist/global',\n    cleanDir: true,\n  },\n});\n"
  },
  {
    "path": "packages/cli/rules/vite-prepare.yml",
    "content": "# husky => vp config\n---\nid: replace-husky\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^husky$'\nfix: vp config\n"
  },
  {
    "path": "packages/cli/rules/vite-tools.yml",
    "content": "# vite --version / vite -v => vp --version / vp -v (global flags, not dev-specific)\n---\nid: replace-vite-version\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vite$'\n  inside:\n    kind: command\n    regex: 'vite\\s+(-v|--version)'\nfix: vp\n\n\n# vite => vp dev (handles all cases: with/without env var prefix and flag args)\n# Match command_name to preserve env var prefix and arguments\n# Excludes subcommands like \"vite build\", \"vite test\", etc.\n---\nid: replace-vite\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vite$'\n  inside:\n    kind: command\n    not:\n      # ignore non-flag arguments (subcommands like build, test, etc.)\n      regex: 'vite\\s+[^-]'\nfix: vp dev\n\n\n# vite <subcommand> => vp <subcommand> (handles vite build, vite test, vite dev, etc.)\n# Match command_name when followed by a subcommand, replace only the command name\n---\nid: replace-vite-subcommand\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vite$'\n  inside:\n    kind: command\n    regex: 'vite\\s+[^-]'\nfix: vp\n\n\n# oxlint => vp lint (handles all cases: with/without env var prefix and args)\n# Match command_name to preserve env var prefix and arguments\n---\nid: replace-oxlint\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^oxlint$'\nfix: vp lint\n\n\n# oxfmt => vp fmt\n---\nid: replace-oxfmt\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^oxfmt$'\nfix: vp fmt\n\n\n# vitest => vp test\n---\nid: replace-vitest\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^vitest$'\nfix: vp test\n\n\n# lint-staged => vp staged\n---\nid: replace-lint-staged\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^lint-staged$'\nfix: vp staged\n\n\n# tsdown => vp pack\n---\nid: replace-tsdown\nlanguage: bash\nrule:\n  kind: command_name\n  regex: '^tsdown$'\nfix: vp pack\n"
  },
  {
    "path": "packages/cli/skills/vite-plus/SKILL.md",
    "content": "---\nname: vite-plus\ndescription: Vite+ skill for development workflow and CLI operations. Use this skill to route user requests to the appropriate bundled Vite+ docs.\n---\n\n# Vite+ Skill\n\nThis skill is a router. Open the minimum relevant docs files under `docs/` and execute.\n\nDocs in this skill are bundled from `docs` during `vite-plus` package build and live at:\n\n- `skills/vite-plus/docs/**/*.md`\n\n## Command Naming\n\nUse `vp` in examples and commands in this skill.\n\n## No-Args Behavior\n\nIf invoked without a concrete task, do a brief project status check and report:\n\n1. Dev server configuration (`vite.config.ts` or `vite.config.js`).\n2. Tool usage (`vp dev`, `vp build`, `vp test`, `vp lint`, `vp fmt`).\n3. Monorepo structure (workspace detection, package manager).\n4. Build configuration (library mode, SSR, custom config).\n\nThen ask what to do next.\n\n## Task Routing\n\n| User intent                       | Docs file(s)                                          |\n| --------------------------------- | ----------------------------------------------------- |\n| Initial setup, getting started    | `docs/guide/index.md`                                 |\n| Dev server, development workflow  | `docs/guide/dev.md`, `docs/guide/index.md`            |\n| Build configuration, optimization | `docs/guide/build.md`, `docs/config/build.md`         |\n| Testing with Vitest               | `docs/guide/test.md`, `docs/config/test.md`           |\n| Linting with Oxlint               | `docs/guide/lint.md`, `docs/config/lint.md`           |\n| Formatting with Oxfmt             | `docs/guide/fmt.md`, `docs/config/fmt.md`             |\n| Check (format, lint, types)       | `docs/guide/check.md`                                 |\n| Monorepo tasks                    | `docs/guide/run.md`, `docs/config/run.md`             |\n| Migration from existing tools     | `docs/guide/migrate.md`                               |\n| Caching and performance           | `docs/guide/cache.md`                                 |\n| Library mode (pack)               | `docs/guide/pack.md`, `docs/config/pack.md`           |\n| Troubleshooting                   | `docs/guide/troubleshooting.md`                       |\n| Configuration overview            | `docs/config/index.md`                                |\n| Staged files / pre-commit         | `docs/guide/commit-hooks.md`, `docs/config/staged.md` |\n| Install dependencies              | `docs/guide/install.md`                               |\n| Node.js version management        | `docs/guide/env.md`                                   |\n| Create a new project              | `docs/guide/create.md`                                |\n| CI setup                          | `docs/guide/ci.md`                                    |\n| IDE integration                   | `docs/guide/ide-integration.md`                       |\n| Upgrade Vite+                     | `docs/guide/upgrade.md`                               |\n| Execute one-off binaries          | `docs/guide/vpx.md`                                   |\n\n## Working Rules\n\n- For multi-topic tasks, combine only the needed doc files.\n- If docs and memory differ, follow docs.\n"
  },
  {
    "path": "packages/cli/snap-tests/bin-oxfmt-wrapper/package.json",
    "content": "{\n  \"name\": \"bin-oxfmt-wrapper\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/bin-oxfmt-wrapper/snap.txt",
    "content": "[1]> node ../node_modules/vite-plus/bin/oxfmt # should reject non-LSP usage\nThis oxfmt wrapper is for IDE extension use only (--lsp mode).\nTo format your code, run: vp fmt\n\n[1]> node ../node_modules/vite-plus/bin/oxfmt --help # should reject non-LSP usage\nThis oxfmt wrapper is for IDE extension use only (--lsp mode).\nTo format your code, run: vp fmt\n\n> node ../node_modules/vite-plus/bin/oxfmt --lsp --help # should exercise import path\nUsage: [-c=PATH] [PATH]...\n\nMode Options:\n        --init               Initialize `.oxfmtrc.json` with default values\n        --migrate=SOURCE     Migrate configuration to `.oxfmtrc.json` from specified source\n                             Available sources: prettier, biome\n        --lsp                Start language server protocol (LSP) server\n        --stdin-filepath=PATH  Specify the file name to use to infer which parser to use\n\nOutput Options:\n        --write              Format and write files in place (default)\n        --check              Check if files are formatted, also show statistics\n        --list-different     List files that would be changed\n\nConfig Options\n    -c, --config=PATH        Path to the configuration file (.json, .jsonc, .ts, .mts, .cts, .js,\n                             .mjs, .cjs)\n\nIgnore Options\n        --ignore-path=PATH   Path to ignore file(s). Can be specified multiple times. If not\n                             specified, .gitignore and .prettierignore in the current directory are\n                             used.\n        --with-node-modules  Format code in node_modules directory (skipped by default)\n\nRuntime Options\n        --no-error-on-unmatched-pattern  Do not exit with error when pattern is unmatched\n        --threads=INT        Number of threads to use. Set to 1 for using only 1 CPU core.\n\nAvailable positional items:\n    PATH                     Single file, path or list of paths. Glob patterns are also supported.\n                             (Be sure to quote them, otherwise your shell may expand them before\n                             passing.) Exclude patterns with `!` prefix like `'!**/fixtures/*.js'`\n                             are also supported. If not provided, current working directory is used.\n\nAvailable options:\n    -h, --help               Prints help information\n    -V, --version            Prints version information\n\n"
  },
  {
    "path": "packages/cli/snap-tests/bin-oxfmt-wrapper/steps.json",
    "content": "{\n  \"commands\": [\n    \"node ../node_modules/vite-plus/bin/oxfmt # should reject non-LSP usage\",\n    \"node ../node_modules/vite-plus/bin/oxfmt --help # should reject non-LSP usage\",\n    \"node ../node_modules/vite-plus/bin/oxfmt --lsp --help # should exercise import path\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/bin-oxlint-wrapper/package.json",
    "content": "{\n  \"name\": \"bin-oxlint-wrapper\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/bin-oxlint-wrapper/snap.txt",
    "content": "[1]> node ../node_modules/vite-plus/bin/oxlint # should reject non-LSP usage\nThis oxlint wrapper is for IDE extension use only (--lsp mode).\nTo lint your code, run: vp lint\n\n[1]> node ../node_modules/vite-plus/bin/oxlint --help # should reject non-LSP usage\nThis oxlint wrapper is for IDE extension use only (--lsp mode).\nTo lint your code, run: vp lint\n\n> node ../node_modules/vite-plus/bin/oxlint --lsp --help # should exercise import path\nUsage: [-c=<./.oxlintrc.json>] [PATH]...\n\nBasic Configuration\n    -c, --config=<./.oxlintrc.json>  Oxlint configuration file\n                              * `.json` and `.jsonc` config files are supported in all runtimes\n                              * JavaScript/TypeScript config files are experimental and require\n                              running via Node.js\n                              * you can use comments in configuration files.\n                              * tries to be compatible with ESLint v8's format\n        --tsconfig=<./tsconfig.json>  Override the TypeScript config used for import resolution.\n                              Oxlint automatically discovers the relevant `tsconfig.json` for each\n                              file. Use this only when your project uses a non-standard tsconfig\n                              name or location.\n        --init                Initialize oxlint configuration with default values\n\nAllowing / Denying Multiple Lints\n   Accumulate rules and categories from left to right on the command-line.\n   For example `-D correctness -A no-debugger` or `-A all -D no-debugger`.\n   The categories are:\n   * `correctness` - Code that is outright wrong or useless (default)\n   * `suspicious`  - Code that is most likely wrong or useless\n   * `pedantic`    - Lints which are rather strict or have occasional false positives\n   * `perf`        - Code that could be written in a more performant way\n   * `style`       - Code that should be written in a more idiomatic way\n   * `restriction` - Lints which prevent the use of language and library features\n   * `nursery`     - New lints that are still under development\n   * `all`         - All categories listed above except `nursery`. Does not enable plugins\n  automatically.\n    -A, --allow=NAME          Allow the rule or category (suppress the lint)\n    -W, --warn=NAME           Deny the rule or category (emit a warning)\n    -D, --deny=NAME           Deny the rule or category (emit an error)\n\nEnable/Disable Plugins\n        --disable-unicorn-plugin  Disable unicorn plugin, which is turned on by default\n        --disable-oxc-plugin  Disable oxc unique rules, which is turned on by default\n        --disable-typescript-plugin  Disable TypeScript plugin, which is turned on by default\n        --import-plugin       Enable import plugin and detect ESM problems.\n        --react-plugin        Enable react plugin, which is turned off by default\n        --jsdoc-plugin        Enable jsdoc plugin and detect JSDoc problems\n        --jest-plugin         Enable the Jest plugin and detect test problems\n        --vitest-plugin       Enable the Vitest plugin and detect test problems\n        --jsx-a11y-plugin     Enable the JSX-a11y plugin and detect accessibility problems\n        --nextjs-plugin       Enable the Next.js plugin and detect Next.js problems\n        --react-perf-plugin   Enable the React performance plugin and detect rendering performance\n                              problems\n        --promise-plugin      Enable the promise plugin and detect promise usage problems\n        --node-plugin         Enable the node plugin and detect node usage problems\n        --vue-plugin          Enable the vue plugin and detect vue usage problems\n\nFix Problems\n        --fix                 Fix as many issues as possible. Only unfixed issues are reported in\n                              the output.\n        --fix-suggestions     Apply auto-fixable suggestions. May change program behavior.\n        --fix-dangerously     Apply dangerous fixes and suggestions\n\nIgnore Files\n        --ignore-path=PATH    Specify the file to use as your `.eslintignore`\n        --ignore-pattern=PAT  Specify patterns of files to ignore (in addition to those in\n                              `.eslintignore`)\n        --no-ignore           Disable excluding files from `.eslintignore` files, --ignore-path\n                              flags and --ignore-pattern flags\n\nHandle Warnings\n        --quiet               Disable reporting on warnings, only errors are reported\n        --deny-warnings       Ensure warnings produce a non-zero exit code\n        --max-warnings=INT    Specify a warning threshold, which can be used to force exit with an\n                              error status if there are too many warning-level rule violations in\n                              your project\n\nOutput\n    -f, --format=ARG          Use a specific output format. Possible values: `checkstyle`,\n                              `default`, `github`, `gitlab`, `json`, `junit`, `stylish`, `unix`\n\nMiscellaneous\n        --silent              Do not display any diagnostics\n        --threads=INT         Number of threads to use. Set to 1 for using only 1 CPU core.\n        --print-config        This option outputs the configuration to be used. When present, no\n                              linting is performed and only config-related options are valid.\n\nInline Configuration Comments\n        --report-unused-disable-directives  Report directive comments like `// oxlint-disable-line`,\n                              when no errors would have been reported on that line anyway\n        --report-unused-disable-directives-severity=SEVERITY  Same as\n                              `--report-unused-disable-directives`, but allows you to specify the\n                              severity level of the reported errors. Only one of these two options\n                              can be used at a time.\n\nAvailable positional items:\n    PATH                      Single file, single path or list of paths\n\nAvailable options:\n        --rules               List all the rules that are currently registered\n        --lsp                 Start the language server\n        --disable-nested-config  Disable the automatic loading of nested configuration files\n        --type-aware          Enable rules that require type information\n        --type-check          Enable experimental type checking (includes TypeScript compiler\n                              diagnostics)\n    -h, --help                Prints help information\n    -V, --version             Prints version information\n\n"
  },
  {
    "path": "packages/cli/snap-tests/bin-oxlint-wrapper/steps.json",
    "content": "{\n  \"commands\": [\n    \"node ../node_modules/vite-plus/bin/oxlint # should reject non-LSP usage\",\n    \"node ../node_modules/vite-plus/bin/oxlint --help # should reject non-LSP usage\",\n    \"node ../node_modules/vite-plus/bin/oxlint --lsp --help # should exercise import path\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/build-vite-env/index.html",
    "content": "<!doctype html>\n<html>\n  <body>\n    <script type=\"module\">\n      console.log('hello');\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "packages/cli/snap-tests/build-vite-env/package.json",
    "content": "{\n  \"name\": \"build-vite-env-test\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/build-vite-env/snap.txt",
    "content": "> VITE_MY_VAR=1 vp run build\n$ vp build\nvite v<semver> building client environment for production...\ntransforming...✓ <variable> modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html                <variable> kB │ gzip: <variable> kB\ndist/assets/index-BnIqjoTZ.js  <variable> kB │ gzip: <variable> kB\n\n✓ built in <variable>ms\n\n\n> VITE_MY_VAR=1 vp run build # should hit cache\n$ vp build ◉ cache hit, replaying\nvite v<semver> building client environment for production...\ntransforming...✓ <variable> modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html                <variable> kB │ gzip: <variable> kB\ndist/assets/index-BnIqjoTZ.js  <variable> kB │ gzip: <variable> kB\n\n✓ built in <variable>ms\n\n---\nvp run: cache hit, <variable>ms saved.\n\n> VITE_MY_VAR=2 vp run build # env changed, should miss cache\n$ vp build ○ cache miss: envs changed, executing\nvite v<semver> building client environment for production...\ntransforming...✓ <variable> modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html                <variable> kB │ gzip: <variable> kB\ndist/assets/index-BnIqjoTZ.js  <variable> kB │ gzip: <variable> kB\n\n✓ built in <variable>ms\n\n---\nvp run: build-vite-env-test#build not cached because it modified its input. (Run `vp run --last-details` for full details)\n"
  },
  {
    "path": "packages/cli/snap-tests/build-vite-env/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"VITE_MY_VAR=1 vp run build\",\n    \"VITE_MY_VAR=1 vp run build # should hit cache\",\n    \"VITE_MY_VAR=2 vp run build # env changed, should miss cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/build-vite-env/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      build: {\n        command: 'vp build',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-clean/package.json",
    "content": "{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"hello\": \"vp fmt\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-clean/snap.txt",
    "content": "> vp run hello # create the cache for 'echo hello'\n$ vp fmt\nFinished in <variable>ms on 4 files using <variable> threads.\n\n---\nvp run: hello not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> vp run hello # hit the cache\n$ vp fmt\nFinished in <variable>ms on 4 files using <variable> threads.\n\n\n> vp cache clean # clean the cache\n> vp run hello # cache miss after clean\n$ vp fmt\nFinished in <variable>ms on 4 files using <variable> threads.\n\n\n> cd subfolder && vp cache clean # cache can be located and cleaned from subfolder\n> vp run hello # cache miss after clean\n$ vp fmt\nFinished in <variable>ms on 4 files using <variable> threads.\n\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-clean/src/index.ts",
    "content": "export const hello = 'world';\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-clean/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [],\n  \"commands\": [\n    \"vp run hello # create the cache for 'echo hello'\",\n    \"vp run hello # hit the cache\",\n    \"vp cache clean # clean the cache\",\n    \"vp run hello # cache miss after clean\",\n    \"cd subfolder && vp cache clean # cache can be located and cleaned from subfolder\",\n    \"vp run hello # cache miss after clean\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-clean/subfolder/.gitkeep",
    "content": ""
  },
  {
    "path": "packages/cli/snap-tests/cache-clean/vite.config.ts",
    "content": "export default {\n  fmt: {},\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-default/hello.mjs",
    "content": "console.log('hello from script');\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-default/package.json",
    "content": "{\n  \"name\": \"cache-scripts-default-test\",\n  \"scripts\": {\n    \"hello\": \"node hello.mjs\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-default/snap.txt",
    "content": "> vp run hello 2>&1 | grep 'cache disabled' # cache should be disabled by default for package.json scripts\n$ node hello.mjs ⊘ cache disabled\n\n> vp run hello 2>&1 | grep 'cache disabled' # second run should also show cache disabled\n$ node hello.mjs ⊘ cache disabled\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-default/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp run hello 2>&1 | grep 'cache disabled' # cache should be disabled by default for package.json scripts\",\n    \"vp run hello 2>&1 | grep 'cache disabled' # second run should also show cache disabled\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-enabled/hello.mjs",
    "content": "console.log('hello from script');\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-enabled/package.json",
    "content": "{\n  \"name\": \"cache-scripts-enabled-test\",\n  \"scripts\": {\n    \"hello\": \"node hello.mjs\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-enabled/snap.txt",
    "content": "> vp run hello # first run should be cache miss\n$ node hello.mjs\nhello from script\n\n\n> vp run hello # second run should be cache hit\n$ node hello.mjs ◉ cache hit, replaying\nhello from script\n\n---\nvp run: cache hit, <variable>ms saved.\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-enabled/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp run hello # first run should be cache miss\",\n    \"vp run hello # second run should be cache hit\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cache-scripts-enabled/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/change-passthrough-env-config/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/change-passthrough-env-config/snap.txt",
    "content": "> MY_ENV=1 vp run hello\n$ node -p process.env.MY_ENV\n1\n\n\n> MY_ENV=2 vp run hello # MY_ENV is pass-through. should hit the cache created in step 1\n$ node -p process.env.MY_ENV ◉ cache hit, replaying\n1\n\n---\nvp run: cache hit, <variable>ms saved.\n\n> # add a new pass through env via VITE_TASK_PASS_THROUGH_ENVS\n> VITE_TASK_PASS_THROUGH_ENVS=MY_ENV,MY_ENV2 MY_ENV=2 vp run hello # cache should be invalidated because untrackedEnv config changed\n$ node -p process.env.MY_ENV ○ cache miss: untracked env config changed, executing\n2\n\n"
  },
  {
    "path": "packages/cli/snap-tests/change-passthrough-env-config/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [],\n  \"commands\": [\n    \"MY_ENV=1 vp run hello\",\n    \"MY_ENV=2 vp run hello # MY_ENV is pass-through. should hit the cache created in step 1\",\n    \"# add a new pass through env via VITE_TASK_PASS_THROUGH_ENVS\",\n    \"VITE_TASK_PASS_THROUGH_ENVS=MY_ENV,MY_ENV2 MY_ENV=2 vp run hello # cache should be invalidated because untrackedEnv config changed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/change-passthrough-env-config/vite.config.ts",
    "content": "const untrackedEnv = process.env.VITE_TASK_PASS_THROUGH_ENVS?.split(',') ?? ['MY_ENV'];\n\nexport default {\n  run: {\n    tasks: {\n      hello: {\n        command: 'node -p process.env.MY_ENV',\n        untrackedEnv,\n        cache: true,\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/check-all-skipped/package.json",
    "content": "{\n  \"name\": \"check-all-skipped\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-all-skipped/snap.txt",
    "content": "[1]> vp check --no-fmt --no-lint\nerror: No checks enabled\n\n`vp check` did not run because both `--no-fmt` and `--no-lint` were set\n"
  },
  {
    "path": "packages/cli/snap-tests/check-all-skipped/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check --no-fmt --no-lint\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fail-fast/package.json",
    "content": "{\n  \"name\": \"check-fail-fast\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fail-fast/snap.txt",
    "content": "[1]> vp check\nerror: Formatting issues found\nsrc/index.js (<variable>ms)\n\nFound formatting issues in 1 file (<variable>ms, <variable> threads). Run `vp check --fix` to fix them.\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fail-fast/src/index.js",
    "content": "function    hello(   )   {\n      eval(  \"code\"  )\n      return    \"hello\"\n}\n\nexport {   hello   }\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fail-fast/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix/package.json",
    "content": "{\n  \"name\": \"check-fix\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix/snap.txt",
    "content": "> vp check --fix\npass: Formatting completed for checked files (<variable>ms)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix/src/index.js",
    "content": "function    hello(   )   {\n      return    \"hello\"\n}\n\nexport {   hello   }\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix/steps.json",
    "content": "{\n  \"commands\": [\"vp check --fix\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-missing-stderr/package.json",
    "content": "{\n  \"name\": \"check-fix-missing-stderr\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-missing-stderr/snap.txt",
    "content": "[1]> vp check --fix\nerror: Formatting could not complete\nFailed to load configuration file.\n<cwd>/vite.config.ts\nError: The `fmt` field in the default export must be an object.\nEnsure the file has a valid default export of a JSON-serializable configuration object.\n\nFormatting failed during fix\n\n[1]> vp check\nerror: Formatting could not start\nFailed to load configuration file.\n<cwd>/vite.config.ts\nError: The `fmt` field in the default export must be an object.\nEnsure the file has a valid default export of a JSON-serializable configuration object.\n\nFormatting failed before analysis started\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-missing-stderr/src/index.js",
    "content": "function hello() {\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-missing-stderr/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp check --fix\",\n    \"vp check\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-missing-stderr/vite.config.ts",
    "content": "export default {\n  fmt: \"invalid\",\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-paths/package.json",
    "content": "{\n  \"name\": \"check-fix-paths\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-paths/snap.txt",
    "content": "> vp check --fix src/index.js\npass: Formatting completed for checked files (<variable>ms)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-paths/src/index.js",
    "content": "function    hello(   )   {\n      return    \"hello\"\n}\n\nexport {   hello   }\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-paths/steps.json",
    "content": "{\n  \"commands\": [\"vp check --fix src/index.js\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-reformat/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"curly\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-reformat/package.json",
    "content": "{\n  \"name\": \"check-fix-reformat\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-reformat/snap.txt",
    "content": "> vp check --fix\npass: Formatting completed for checked files (<variable>ms)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n\n> vp check # should pass after fix\npass: All 4 files are correctly formatted (<variable>ms, <variable> threads)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-reformat/src/index.js",
    "content": "function hello(x) {\n  if (x) return \"hello\";\n  return \"world\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fix-reformat/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp check --fix\",\n    \"vp check # should pass after fix\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fmt-fail/package.json",
    "content": "{\n  \"name\": \"check-fmt-fail\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fmt-fail/snap.txt",
    "content": "[1]> vp check\nerror: Formatting issues found\nsrc/index.js (<variable>ms)\nsteps.json (<variable>ms)\n\nFound formatting issues in 2 files (<variable>ms, <variable> threads). Run `vp check --fix` to fix them.\n\n> vp check --fix\npass: Formatting completed for checked files (<variable>ms)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n\n> vp check # should pass after fix\npass: All 3 files are correctly formatted (<variable>ms, <variable> threads)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fmt-fail/src/index.js",
    "content": "function    hello(   )   {\n      return    \"hello\"\n}\n\nexport {   hello   }\n"
  },
  {
    "path": "packages/cli/snap-tests/check-fmt-fail/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp check\",\n    \"vp check --fix\",\n    \"vp check # should pass after fix\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-eval\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail/package.json",
    "content": "{\n  \"name\": \"check-lint-fail\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail/snap.txt",
    "content": "[1]> vp check\npass: All 4 files are correctly formatted (<variable>ms, <variable> threads)\nerror: Lint issues found\n× eslint(no-eval): eval can be harmful.\n   ╭─[src/index.js:2:3]\n 1 │ function hello() {\n 2 │   eval(\"code\");\n   ·   ────\n 3 │   return \"hello\";\n   ╰────\n  help: Avoid eval(). For JSON parsing use JSON.parse(); for dynamic property access use bracket notation (obj[key]); for other cases refactor to avoid evaluating strings as code.\n\nFound 1 error and 0 warnings in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail/src/index.js",
    "content": "function hello() {\n  eval(\"code\");\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-no-typecheck/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-eval\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-no-typecheck/package.json",
    "content": "{\n  \"name\": \"check-lint-fail-no-typecheck\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-no-typecheck/snap.txt",
    "content": "[1]> vp check\npass: All 5 files are correctly formatted (<variable>ms, <variable> threads)\nerror: Lint issues found\n× eslint(no-eval): eval can be harmful.\n   ╭─[src/index.js:2:3]\n 1 │ function hello() {\n 2 │   eval(\"code\");\n   ·   ────\n 3 │   return \"hello\";\n   ╰────\n  help: Avoid eval(). For JSON parsing use JSON.parse(); for dynamic property access use bracket notation (obj[key]); for other cases refactor to avoid evaluating strings as code.\n\nFound 1 error and 0 warnings in 2 files (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-no-typecheck/src/index.js",
    "content": "function hello() {\n  eval(\"code\");\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-no-typecheck/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-no-typecheck/vite.config.ts",
    "content": "export default {\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: false,\n    },\n    rules: {\n      \"no-eval\": \"error\",\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-typecheck/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-eval\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-typecheck/package.json",
    "content": "{\n  \"name\": \"check-lint-fail-typecheck\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-typecheck/snap.txt",
    "content": "[1]> vp check\npass: All 5 files are correctly formatted (<variable>ms, <variable> threads)\nerror: Lint or type issues found\n× eslint(no-eval): eval can be harmful.\n   ╭─[src/index.js:2:3]\n 1 │ function hello() {\n 2 │   eval(\"code\");\n   ·   ────\n 3 │   return \"hello\";\n   ╰────\n  help: Avoid eval(). For JSON parsing use JSON.parse(); for dynamic property access use bracket notation (obj[key]); for other cases refactor to avoid evaluating strings as code.\n\nFound 1 error and 0 warnings in 2 files (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-typecheck/src/index.js",
    "content": "function hello() {\n  eval(\"code\");\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-typecheck/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-fail-typecheck/vite.config.ts",
    "content": "export default {\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n    rules: {\n      \"no-eval\": \"error\",\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-warn/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-console\": \"warn\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-warn/package.json",
    "content": "{\n  \"name\": \"check-lint-warn\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-warn/snap.txt",
    "content": "> vp check\npass: All 4 files are correctly formatted (<variable>ms, <variable> threads)\nwarn: Lint warnings found\n⚠ eslint(no-console): Unexpected console statement.\n   ╭─[src/index.js:2:3]\n 1 │ function hello() {\n 2 │   console.log(\"hello\");\n   ·   ───────────\n 3 │ }\n   ╰────\n  help: Delete this console statement.\n\nFound 0 errors and 1 warning in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-warn/src/index.js",
    "content": "function hello() {\n  console.log(\"hello\");\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-lint-warn/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-fmt/package.json",
    "content": "{\n  \"name\": \"check-no-fmt\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-fmt/snap.txt",
    "content": "> vp check --no-fmt\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-fmt/src/index.js",
    "content": "function    hello(   )   {\n  return    \"hello\"\n}\n\nexport {   hello   }\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-fmt/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check --no-fmt\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-lint/package.json",
    "content": "{\n  \"name\": \"check-no-lint\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-lint/snap.txt",
    "content": "> vp check --no-lint\npass: All 3 files are correctly formatted (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-lint/src/index.js",
    "content": "function hello() {\n  eval(\"code\");\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-no-lint/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check --no-lint\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-oxlint-env/package.json",
    "content": "{\n  \"name\": \"check-oxlint-env\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-oxlint-env/snap.txt",
    "content": "[1]> OXLINT_TSGOLINT_PATH=./invalid-path vp lint --type-aware # should error that ./invalid-path doesn't exist\nFailed to find tsgolint executable: OXLINT_TSGOLINT_PATH points to './invalid-path' which does not exist"
  },
  {
    "path": "packages/cli/snap-tests/check-oxlint-env/steps.json",
    "content": "{\n  \"commands\": [\n    \"OXLINT_TSGOLINT_PATH=./invalid-path vp lint --type-aware # should error that ./invalid-path doesn't exist\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass/package.json",
    "content": "{\n  \"name\": \"check-pass\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass/snap.txt",
    "content": "> vp check\npass: All 3 files are correctly formatted (<variable>ms, <variable> threads)\npass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass/src/index.js",
    "content": "function hello() {\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-no-typecheck/package.json",
    "content": "{\n  \"name\": \"check-pass-no-typecheck\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-no-typecheck/snap.txt",
    "content": "> vp check\npass: All 4 files are correctly formatted (<variable>ms, <variable> threads)\npass: Found no warnings or lint errors in 2 files (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-no-typecheck/src/index.js",
    "content": "function hello() {\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-no-typecheck/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-no-typecheck/vite.config.ts",
    "content": "export default {\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: false,\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck/package.json",
    "content": "{\n  \"name\": \"check-pass-typecheck\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck/snap.txt",
    "content": "> vp check\npass: All 4 files are correctly formatted (<variable>ms, <variable> threads)\npass: Found no warnings, lint errors, or type errors in 2 files (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck/src/index.js",
    "content": "function hello() {\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck/vite.config.ts",
    "content": "export default {\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck-github-actions/package.json",
    "content": "{\n  \"name\": \"check-pass-typecheck-github-actions\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck-github-actions/snap.txt",
    "content": "> vp check\npass: All 4 files are correctly formatted (<variable>ms, <variable> threads)\npass: Found no warnings, lint errors, or type errors in 2 files (<variable>ms, <variable> threads)\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck-github-actions/src/index.js",
    "content": "function hello() {\n  return \"hello\";\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck-github-actions/steps.json",
    "content": "{\n  \"env\": {\n    \"GITHUB_ACTIONS\": \"true\",\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/check-pass-typecheck-github-actions/vite.config.ts",
    "content": "export default {\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/cli-helper-message/package.json",
    "content": "{\n  \"name\": \"cli-helper-message\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/cli-helper-message/snap.txt",
    "content": "> vp -h # show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp <COMMAND>\n\nCore Commands:\n  dev            Run the development server\n  build          Build for production\n  test           Run tests\n  lint           Lint code\n  fmt            Format code\n  check          Run format, lint, and type checks\n  pack           Build library\n  run            Run tasks\n  exec           Execute a command from local node_modules/.bin\n  preview        Preview production build\n  cache          Manage the task cache\n  config         Configure hooks and agent integration\n  staged         Run linters on staged files\n\nPackage Manager Commands:\n  install    Install all dependencies, or add packages if package names are provided\n\nOptions:\n  -h, --help  Print help\n\n> vp -V # show version\nVITE+ - The Unified Toolchain for the Web\n\nvp v<semver>\n\nLocal vite-plus:\n  vite-plus  Not found\n\n"
  },
  {
    "path": "packages/cli/snap-tests/cli-helper-message/steps.json",
    "content": "{\n  \"commands\": [\"vp -h # show help message\", \"vp -V # show version\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-dev-with-port/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-dev-with-port/snap.txt",
    "content": "> vp dev --port 12312312312 2>&1 | grep -E '(RangeError|No available ports)' # intentionally use an invalid port (exceeds 0-65535) to trigger port error\nError: No available ports found between 12312312312 and 65535\n"
  },
  {
    "path": "packages/cli/snap-tests/command-dev-with-port/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp dev --port 12312312312 2>&1 | grep -E '(RangeError|No available ports)' # intentionally use an invalid port (exceeds 0-65535) to trigger port error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/api-examples.md",
    "content": "---\noutline: deep\n---\n\n# Runtime API Examples\n\nThis page demonstrates usage of some of the runtime APIs provided by VitePress.\n\nThe main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files:\n\n```md\n<script setup>\nimport { useData } from 'vitepress'\n\nconst { theme, page, frontmatter } = useData()\n</script>\n\n## Results\n\n### Theme Data\n\n<pre>{{ theme }}</pre>\n\n### Page Data\n\n<pre>{{ page }}</pre>\n\n### Page Frontmatter\n\n<pre>{{ frontmatter }}</pre>\n```\n\n<script setup>\nimport { useData } from 'vitepress'\n\nconst { site, theme, page, frontmatter } = useData()\n</script>\n\n## Results\n\n### Theme Data\n\n<pre>{{ theme }}</pre>\n\n### Page Data\n\n<pre>{{ page }}</pre>\n\n### Page Frontmatter\n\n<pre>{{ frontmatter }}</pre>\n\n## More\n\nCheck out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata).\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  name: 'My Awesome Project'\n  text: 'A VitePress Site'\n  tagline: My great project tagline\n  actions:\n    - theme: brand\n      text: Markdown Examples\n      link: /markdown-examples\n    - theme: alt\n      text: API Examples\n      link: /api-examples\n\nfeatures:\n  - title: Feature A\n    details: Lorem ipsum dolor sit amet, consectetur adipiscing elit\n  - title: Feature B\n    details: Lorem ipsum dolor sit amet, consectetur adipiscing elit\n  - title: Feature C\n    details: Lorem ipsum dolor sit amet, consectetur adipiscing elit\n---\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/markdown-examples.md",
    "content": "# Markdown Extension Examples\n\nThis page demonstrates some of the built-in markdown extensions provided by VitePress.\n\n## Syntax Highlighting\n\nVitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting:\n\n**Input**\n\n````md\n```js{4}\nexport default {\n  data () {\n    return {\n      msg: 'Highlighted!'\n    }\n  }\n}\n```\n````\n\n**Output**\n\n```js{4}\nexport default {\n  data () {\n    return {\n      msg: 'Highlighted!'\n    }\n  }\n}\n```\n\n## Custom Containers\n\n**Input**\n\n```md\n::: info\nThis is an info box.\n:::\n\n::: tip\nThis is a tip.\n:::\n\n::: warning\nThis is a warning.\n:::\n\n::: danger\nThis is a dangerous warning.\n:::\n\n::: details\nThis is a details block.\n:::\n```\n\n**Output**\n\n::: info\nThis is an info box.\n:::\n\n::: tip\nThis is a tip.\n:::\n\n::: warning\nThis is a warning.\n:::\n\n::: danger\nThis is a dangerous warning.\n:::\n\n::: details\nThis is a details block.\n:::\n\n## More\n\nCheck out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown).\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/package.json",
    "content": "{\n  \"scripts\": {\n    \"docs:dev\": \"vite doc dev\",\n    \"docs:build\": \"vite doc build\",\n    \"docs:preview\": \"vite doc preview\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/snap.txt",
    "content": "> vite doc build # build documentation\n\n  vitepress v<semver>\n\n- building client + server bundles...\n\u001b[32m✓\u001b[0m building client + server bundles...\n- rendering pages...\n\u001b[32m✓\u001b[0m rendering pages...\nbuild complete in <variable>ms.\n\n\n> vite doc build # build documentation again should hit the cache\n✓ cache hit, replaying\n\n  vitepress v<semver>\n\n- building client + server bundles...\n\u001b[32m✓\u001b[0m building client + server bundles...\n- rendering pages...\n\u001b[32m✓\u001b[0m rendering pages...\nbuild complete in <variable>ms.\n\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\", \"darwin\", \"linux\"],\n  \"commands\": [\n    \"vp run doc # build documentation\",\n    \"vp run doc # build documentation again should hit the cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-doc/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      doc: {\n        command: 'vp doc build',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec/package.json",
    "content": "{\n  \"scripts\": {\n    \"foo\": \"vp exec node -e \\\"console.log(5173)\\\"\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec/setup-bin.js",
    "content": "const fs = require('fs');\nfs.mkdirSync('node_modules/.bin', { recursive: true });\nfs.writeFileSync(\n  'node_modules/.bin/hello-test',\n  '#!/usr/bin/env node\\nconsole.log(\"hello from test-bin\");\\n',\n  { mode: 0o755 },\n);\nfs.writeFileSync('node_modules/.bin/hello-test.cmd', '@node \"%~dp0\\\\hello-test\" %*\\n');\n\n// Create subdir with a local executable for cwd resolution test\nfs.mkdirSync('subdir', { recursive: true });\nfs.writeFileSync('subdir/my-local', '#!/usr/bin/env node\\nconsole.log(\"hello from subdir\");\\n', {\n  mode: 0o755,\n});\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec/snap.txt",
    "content": "> node setup-bin.js\n> vp exec hello-test # exec binary from node_modules/.bin\nhello from test-bin\n\n> vp exec echo hello # basic exec\nhello\n\n> vp exec -- echo with-separator # explicit -- separator\nwith-separator\n\n> vp exec node -e \"console.log('from node')\" # exec node with args\nfrom node\n\n> vp exec -c 'echo hello from shell' # shell mode\nhello from shell\n\n> vp exec --parallel -- echo hello # --parallel with single package should stream output\nhello\n\n> cd subdir && vp exec ./my-local # resolve relative executable from caller cwd\nhello from subdir\n\n> vp exec --help # help message\nExecute a command from local node_modules/.bin\n\nUsage: vp exec [OPTIONS] [COMMAND]...\n\nArguments:\n  [COMMAND]...\n          Command and arguments to execute\n\nOptions:\n  -r, --recursive\n          Select all packages in the workspace\n\n  -t, --transitive\n          Select the current package and its transitive dependencies\n\n  -w, --workspace-root\n          Select the workspace root package\n\n  -F, --filter <FILTERS>\n          Match packages by name, directory, or glob pattern.\n          \n            --filter <pattern>        Select by package name (e.g. foo, @scope/*)\n            --filter ./<dir>          Select packages under a directory\n            --filter {<dir>}          Same as ./<dir>, but allows traversal suffixes\n            --filter <pattern>...     Select package and its dependencies\n            --filter ...<pattern>     Select package and its dependents\n            --filter <pattern>^...    Select only the dependencies (exclude the package itself)\n            --filter !<pattern>       Exclude packages matching the pattern\n\n  -c, --shell-mode\n          Execute the command within a shell environment\n\n      --parallel\n          Run concurrently without topological ordering\n\n      --reverse\n          Reverse execution order\n\n      --resume-from <RESUME_FROM>\n          Resume from a specific package\n\n      --report-summary\n          Save results to vp-exec-summary.json\n\n  -h, --help\n          Print help (see a summary with '-h')\n\nExamples:\n  vp exec node --version                             # Run local node\n  vp exec tsc --noEmit                               # Run local TypeScript compiler\n  vp exec -c 'tsc --noEmit && prettier --check .'    # Shell mode\n  vp exec -r -- tsc --noEmit                         # Run in all workspace packages\n  vp exec --filter 'app...' -- tsc                   # Run in filtered packages\n\n[1]> vp exec # missing command should error\nerror: 'vp exec' requires a command to run\n\nUsage: vp exec [--] <command> [args...]\n\nExamples:\n  vp exec node --version\n  vp exec tsc --noEmit\n\n[1]> vp exec nonexistent-cmd-12345 # command not found error\nerror: Command 'nonexistent-cmd-12345' not found in node_modules/.bin\n\nRun `vp install` to install dependencies, or use `vpx` for invoking remote commands.\n\n> vp run foo # vp exec works in package.json scripts\n$ vp exec node -e \"console.log(5173)\" ⊘ cache disabled\n5173\n\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    { \"command\": \"node setup-bin.js\", \"ignoreOutput\": true },\n    \"vp exec hello-test # exec binary from node_modules/.bin\",\n    \"vp exec echo hello # basic exec\",\n    \"vp exec -- echo with-separator # explicit -- separator\",\n    \"vp exec node -e \\\"console.log('from node')\\\" # exec node with args\",\n    \"vp exec -c 'echo hello from shell' # shell mode\",\n    \"vp exec --parallel -- echo hello # --parallel with single package should stream output\",\n    \"cd subdir && vp exec ./my-local # resolve relative executable from caller cwd\",\n    \"vp exec --help # help message\",\n    \"vp exec # missing command should error\",\n    \"vp exec nonexistent-cmd-12345 # command not found error\",\n    \"vp run foo # vp exec works in package.json scripts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-cwd/package.json",
    "content": "{\n  \"scripts\": {\n    \"print-cwd\": \"vp exec -c pwd\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-cwd/setup.js",
    "content": "const fs = require('fs');\n// Create a subdirectory to test cwd preservation\nfs.mkdirSync('src/nested', { recursive: true });\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-cwd/snap.txt",
    "content": "> node setup.js\n> vp exec -c 'basename $(pwd)' # cwd is package root\ncommand-exec-cwd\n\n> cd src && vp exec -c 'basename $(pwd)' # cwd preserved in subdirectory\nsrc\n\n> cd src/nested && vp exec -c 'basename $(pwd)' # cwd preserved in nested subdirectory\nnested\n\n> cd src && vp exec node -e \"const p = require('path'); console.log(p.basename(process.cwd()))\" # non-shell mode also preserves cwd\nsrc\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-cwd/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    { \"command\": \"node setup.js\", \"ignoreOutput\": true },\n    \"vp exec -c 'basename $(pwd)' # cwd is package root\",\n    \"cd src && vp exec -c 'basename $(pwd)' # cwd preserved in subdirectory\",\n    \"cd src/nested && vp exec -c 'basename $(pwd)' # cwd preserved in nested subdirectory\",\n    \"cd src && vp exec node -e \\\"const p = require('path'); console.log(p.basename(process.cwd()))\\\" # non-shell mode also preserves cwd\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo/package.json",
    "content": "{\n  \"name\": \"exec-monorepo\",\n  \"workspaces\": [\n    \"packages/*\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo/packages/app-a/package.json",
    "content": "{\n  \"name\": \"app-a\",\n  \"dependencies\": {\n    \"lib-c\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo/packages/app-b/package.json",
    "content": "{\n  \"name\": \"app-b\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo/packages/lib-c/package.json",
    "content": "{\n  \"name\": \"lib-c\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo/snap.txt",
    "content": "> vp exec -r -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # recursive exec with env var\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\n\n> vp exec --filter app-* -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # glob filter\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\n\n> vp exec --filter lib-c -- echo lib-only # exact name filter\nlib-only\n\n> vp exec --filter app-a... -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter with dependencies\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\n\n> vp exec --filter app-a^... -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # dependencies only, exclude self\nlib-c\n\n> vp exec -r --parallel -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # parallel mode\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\n\n> vp exec -r -c 'echo shell-$VITE_PLUS_PACKAGE_NAME' # recursive + shell mode\nlib-c$ echo shell-$VITE_PLUS_PACKAGE_NAME\nshell-lib-c\napp-a$ echo shell-$VITE_PLUS_PACKAGE_NAME\nshell-app-a\napp-b$ echo shell-$VITE_PLUS_PACKAGE_NAME\nshell-app-b\nexec-monorepo$ echo shell-$VITE_PLUS_PACKAGE_NAME\nshell-exec-monorepo\n\n> vp exec --filter ./packages/app-a -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # path filter\napp-a\n\n> vp exec --filter '{./packages/app-a}' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # braced path filter\napp-a\n\n> vp exec --filter '{./packages/app-a}...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # braced path filter with deps\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\n\n> vp exec -r --reverse -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # reverse order\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\n\n> vp exec -r --resume-from lib-c -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # resume from\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\n\n> vp exec --filter '!app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # exclusion-only filter\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\n\n> vp exec --filter '!app-a' --filter '!app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # multiple exclusion-only filters\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\n\n> vp exec --filter app-a --filter lib-c -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # multiple inclusion filters (union)\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\n\n> vp exec --filter 'app-*' --filter '!app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # mixed inclusion + exclusion filters\napp-a\n\n> vp exec --filter '!no-such-pkg' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # exclusion of nonexistent pkg returns all\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\n\n> vp exec --filter='app-*' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # equals-form filter\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\n\n> vp exec --filter '*' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # glob star includes root\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\n\n> vp exec -w -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # workspace root only\nexec-monorepo\n\n> vp exec -r --report-summary -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # report summary\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\nexec-monorepo$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo\n\n> node -e \"const r=JSON.parse(require('fs').readFileSync('vp-exec-summary.json','utf8'));const s=r.executionStatus;for(const[k,v]of Object.entries(s)){console.log(k+': '+v.status+' '+(typeof v.duration==='number'?'has_duration':'no_duration'))}\" # verify summary file\napp-a: passed has_duration\napp-b: passed has_duration\nexec-monorepo: passed has_duration\nlib-c: passed has_duration\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp exec -r -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # recursive exec with env var\",\n    \"vp exec --filter app-* -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # glob filter\",\n    \"vp exec --filter lib-c -- echo lib-only # exact name filter\",\n    \"vp exec --filter app-a... -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # filter with dependencies\",\n    \"vp exec --filter app-a^... -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # dependencies only, exclude self\",\n    \"vp exec -r --parallel -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # parallel mode\",\n    \"vp exec -r -c 'echo shell-$VITE_PLUS_PACKAGE_NAME' # recursive + shell mode\",\n    \"vp exec --filter ./packages/app-a -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # path filter\",\n    \"vp exec --filter '{./packages/app-a}' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # braced path filter\",\n    \"vp exec --filter '{./packages/app-a}...' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # braced path filter with deps\",\n    \"vp exec -r --reverse -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # reverse order\",\n    \"vp exec -r --resume-from lib-c -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # resume from\",\n    \"vp exec --filter '!app-b' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # exclusion-only filter\",\n    \"vp exec --filter '!app-a' --filter '!app-b' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # multiple exclusion-only filters\",\n    \"vp exec --filter app-a --filter lib-c -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # multiple inclusion filters (union)\",\n    \"vp exec --filter 'app-*' --filter '!app-b' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # mixed inclusion + exclusion filters\",\n    \"vp exec --filter '!no-such-pkg' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # exclusion of nonexistent pkg returns all\",\n    \"vp exec --filter='app-*' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # equals-form filter\",\n    \"vp exec --filter '*' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # glob star includes root\",\n    \"vp exec -w -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # workspace root only\",\n    \"vp exec -r --report-summary -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # report summary\",\n    \"node -e \\\"const r=JSON.parse(require('fs').readFileSync('vp-exec-summary.json','utf8'));const s=r.executionStatus;for(const[k,v]of Object.entries(s)){console.log(k+': '+v.status+' '+(typeof v.duration==='number'?'has_duration':'no_duration'))}\\\" # verify summary file\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-filter-v2/package.json",
    "content": "{\n  \"name\": \"exec-monorepo-filter-v2\",\n  \"workspaces\": [\n    \"packages/*\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-filter-v2/packages/app-a/package.json",
    "content": "{\n  \"name\": \"app-a\",\n  \"dependencies\": {\n    \"lib-c\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-filter-v2/packages/app-b/package.json",
    "content": "{\n  \"name\": \"app-b\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-filter-v2/packages/lib-c/package.json",
    "content": "{\n  \"name\": \"lib-c\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-filter-v2/snap.txt",
    "content": "> vp exec -F app-a -- echo hello # -F short flag for --filter\nhello\n\n> vp exec -F app-a -F lib-c -- echo hello # -F short flag multiple\nlib-c$ echo hello\nhello\napp-a$ echo hello\nhello\n\n> vp exec -F 'app-*' -- echo hello # -F short flag with glob\napp-a$ echo hello\nhello\napp-b$ echo hello\nhello\n\n> vp exec --filter 'app-a lib-c' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # whitespace splitting: 'a b' => two filters\nlib-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-c\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\n\n> vp exec --filter 'app-a  app-b' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # whitespace splitting with extra spaces\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\n\n> vp exec --filter nonexistent-pkg -- echo hello # unmatched filter warning\nwarn: No packages matched the filter 'nonexistent-pkg'\nwarn: No packages matched the filter(s)\n\n> vp exec --filter nonexistent-pkg --filter app-a -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # unmatched + matched filter\nwarn: No packages matched the filter 'nonexistent-pkg'\napp-a\n\n> vp exec -w --filter app-a -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # -w + --filter is additive (root + filtered)\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\nexec-monorepo-filter-v2$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo-filter-v2\n\n> vp exec -w --filter 'app-*' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # -w + glob filter additive\napp-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-a\nexec-monorepo-filter-v2$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo-filter-v2\napp-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-b\n\n[1]> vp exec -r --filter app-a -- echo hello # -r + --filter conflict error\nerror: --filter and --recursive cannot be used together\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-filter-v2/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp exec -F app-a -- echo hello # -F short flag for --filter\",\n    \"vp exec -F app-a -F lib-c -- echo hello # -F short flag multiple\",\n    \"vp exec -F 'app-*' -- echo hello # -F short flag with glob\",\n    \"vp exec --filter 'app-a lib-c' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # whitespace splitting: 'a b' => two filters\",\n    \"vp exec --filter 'app-a  app-b' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # whitespace splitting with extra spaces\",\n    \"vp exec --filter nonexistent-pkg -- echo hello # unmatched filter warning\",\n    \"vp exec --filter nonexistent-pkg --filter app-a -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # unmatched + matched filter\",\n    \"vp exec -w --filter app-a -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # -w + --filter is additive (root + filtered)\",\n    \"vp exec -w --filter 'app-*' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # -w + glob filter additive\",\n    \"vp exec -r --filter app-a -- echo hello # -r + --filter conflict error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/package.json",
    "content": "{\n  \"name\": \"exec-monorepo-order\",\n  \"workspaces\": [\n    \"packages/*\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/app-mobile/package.json",
    "content": "{\n  \"name\": \"app-mobile\",\n  \"dependencies\": {\n    \"lib-ui\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/app-web/package.json",
    "content": "{\n  \"name\": \"app-web\",\n  \"dependencies\": {\n    \"lib-ui\": \"workspace:*\",\n    \"lib-utils\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-a/package.json",
    "content": "{\n  \"name\": \"cycle-a\",\n  \"dependencies\": {\n    \"cycle-b\": \"workspace:*\",\n    \"lib-core\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-b/package.json",
    "content": "{\n  \"name\": \"cycle-b\",\n  \"dependencies\": {\n    \"cycle-a\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-c/package.json",
    "content": "{\n  \"name\": \"cycle-c\",\n  \"dependencies\": {\n    \"cycle-d\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-d/package.json",
    "content": "{\n  \"name\": \"cycle-d\",\n  \"dependencies\": {\n    \"cycle-e\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/cycle-e/package.json",
    "content": "{\n  \"name\": \"cycle-e\",\n  \"dependencies\": {\n    \"cycle-c\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-core/package.json",
    "content": "{\n  \"name\": \"lib-core\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-ui/package.json",
    "content": "{\n  \"name\": \"lib-ui\",\n  \"dependencies\": {\n    \"lib-core\": \"workspace:*\",\n    \"lib-utils\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/packages/lib-utils/package.json",
    "content": "{\n  \"name\": \"lib-utils\",\n  \"dependencies\": {\n    \"lib-core\": \"workspace:*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/snap.txt",
    "content": "> vp exec -r -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # recursive: topological order\nlib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-core\nlib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-utils\nlib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-ui\napp-mobile$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-mobile\napp-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-web\ncycle-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-b\ncycle-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-a\ncycle-e$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-e\ncycle-d$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-d\ncycle-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-c\nexec-monorepo-order$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo-order\n\n> vp exec --filter 'app-web...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter with transitive deps\nlib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-core\nlib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-utils\nlib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-ui\napp-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-web\n\n> vp exec --filter 'lib-ui...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter mid-graph with deps\nlib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-core\nlib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-utils\nlib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-ui\n\n> vp exec --filter '...lib-core' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # filter dependents of foundation\nlib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-core\nlib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-utils\nlib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-ui\napp-mobile$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-mobile\ncycle-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-a\ncycle-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-b\napp-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-web\n\n> vp exec -r --reverse -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # reverse topological order\nexec-monorepo-order$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nexec-monorepo-order\ncycle-c$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-c\ncycle-d$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-d\ncycle-e$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-e\ncycle-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-a\ncycle-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-b\napp-web$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-web\napp-mobile$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\napp-mobile\nlib-ui$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-ui\nlib-utils$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-utils\nlib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-core\n\n> vp exec --filter 'cycle-a...' -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\" # cycle member with non-cyclic dep: lib-core before cycle-a\nlib-core$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\nlib-core\ncycle-a$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-a\ncycle-b$ node -e console.log(process.env.VITE_PLUS_PACKAGE_NAME)\ncycle-b\n"
  },
  {
    "path": "packages/cli/snap-tests/command-exec-monorepo-order/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp exec -r -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # recursive: topological order\",\n    \"vp exec --filter 'app-web...' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # filter with transitive deps\",\n    \"vp exec --filter 'lib-ui...' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # filter mid-graph with deps\",\n    \"vp exec --filter '...lib-core' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # filter dependents of foundation\",\n    \"vp exec -r --reverse -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # reverse topological order\",\n    \"vp exec --filter 'cycle-a...' -- node -e \\\"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\\\" # cycle member with non-cyclic dep: lib-core before cycle-a\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-helper/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-helper/snap.txt",
    "content": "> vp -h # help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp <COMMAND>\n\nCore Commands:\n  dev            Run the development server\n  build          Build for production\n  test           Run tests\n  lint           Lint code\n  fmt            Format code\n  check          Run format, lint, and type checks\n  pack           Build library\n  run            Run tasks\n  exec           Execute a command from local node_modules/.bin\n  preview        Preview production build\n  cache          Manage the task cache\n  config         Configure hooks and agent integration\n  staged         Run linters on staged files\n\nPackage Manager Commands:\n  install    Install all dependencies, or add packages if package names are provided\n\nOptions:\n  -h, --help  Print help\n\n> vp pack -h # pack help message\nvp pack\n\nUsage:\n  $ vp pack [...files]\n\nCommands:\n  [...files]  Bundle files\n\nFor more info, run any command with the `--help` flag:\n  $ vp pack --help\n\nOptions:\n  --config-loader <loader>      Config loader to use: auto, native, unrun (default: auto)\n  --no-config                   Disable config file (default: true)\n  -f, --format <format>         Bundle format: esm, cjs, iife, umd (default: esm)\n  --clean                       Clean output directory, --no-clean to disable \n  --deps.never-bundle <module>  Mark dependencies as external \n  --minify                      Minify output \n  --devtools                    Enable devtools integration \n  --debug [feat]                Show debug logs \n  --target <target>             Bundle target, e.g \"es2015\", \"esnext\" \n  -l, --logLevel <level>        Set log level: info, warn, error, silent \n  --fail-on-warn                Fail on warnings (default: true)\n  --no-write                    Disable writing files to disk, incompatible with watch mode (default: true)\n  -d, --out-dir <dir>           Output directory (default: dist)\n  --treeshake                   Tree-shake bundle (default: true)\n  --sourcemap                   Generate source map (default: false)\n  --shims                       Enable cjs and esm shims (default: false)\n  --platform <platform>         Target platform (default: node)\n  --dts                         Generate dts files \n  --publint                     Enable publint (default: false)\n  --attw                        Enable Are the types wrong integration (default: false)\n  --unused                      Enable unused dependencies check (default: false)\n  -w, --watch [path]            Watch mode \n  --ignore-watch <path>         Ignore custom paths in watch mode \n  --from-vite [vitest]          Reuse config from Vite or Vitest \n  --report                      Size report (default: true)\n  --env.* <value>               Define compile-time env variables \n  --env-file <file>             Load environment variables from a file, when used together with --env, variables in --env take precedence \n  --env-prefix <prefix>         Prefix for env variables to inject into the bundle (default: VITE_PACK_,TSDOWN_)\n  --on-success <command>        Command to run on success \n  --copy <dir>                  Copy files to output dir \n  --public-dir <dir>            Alias for --copy, deprecated \n  --tsconfig <tsconfig>         Set tsconfig path \n  --unbundle                    Unbundle mode \n  --root <dir>                  Root directory of input files \n  --exe                         Bundle as executable \n  -W, --workspace [dir]         Enable workspace mode \n  -F, --filter <pattern>        Filter configs (cwd or name), e.g. /pkg-name$/ or pkg-name \n  --exports                     Generate export-related metadata for package.json (experimental) \n  -h, --help                    Display this message \n\n> vp fmt -h # fmt help message\nUsage: [-c=PATH] [PATH]...\n\nMode Options:\n        --init               Initialize `.oxfmtrc.json` with default values\n        --migrate=SOURCE     Migrate configuration to `.oxfmtrc.json` from specified source\n                             Available sources: prettier, biome\n        --lsp                Start language server protocol (LSP) server\n        --stdin-filepath=PATH  Specify the file name to use to infer which parser to use\n\nOutput Options:\n        --write              Format and write files in place (default)\n        --check              Check if files are formatted, also show statistics\n        --list-different     List files that would be changed\n\nConfig Options\n    -c, --config=PATH        Path to the configuration file (.json, .jsonc, .ts, .mts, .cts, .js,\n                             .mjs, .cjs)\n\nIgnore Options\n        --ignore-path=PATH   Path to ignore file(s). Can be specified multiple times. If not\n                             specified, .gitignore and .prettierignore in the current directory are\n                             used.\n        --with-node-modules  Format code in node_modules directory (skipped by default)\n\nRuntime Options\n        --no-error-on-unmatched-pattern  Do not exit with error when pattern is unmatched\n        --threads=INT        Number of threads to use. Set to 1 for using only 1 CPU core.\n\nAvailable positional items:\n    PATH                     Single file, path or list of paths. Glob patterns are also supported.\n                             (Be sure to quote them, otherwise your shell may expand them before\n                             passing.) Exclude patterns with `!` prefix like `'!**/fixtures/*.js'`\n                             are also supported. If not provided, current working directory is used.\n\nAvailable options:\n    -h, --help               Prints help information\n    -V, --version            Prints version information\n\n\n> vp lint -h # lint help message\nUsage: [-c=<./.oxlintrc.json>] [PATH]...\n\nBasic Configuration\n    -c, --config=<./.oxlintrc.json>  Oxlint configuration file\n                              * `.json` and `.jsonc` config files are supported in all runtimes\n                              * JavaScript/TypeScript config files are experimental and require\n                              running via Node.js\n                              * you can use comments in configuration files.\n                              * tries to be compatible with ESLint v8's format\n        --tsconfig=<./tsconfig.json>  Override the TypeScript config used for import resolution.\n                              Oxlint automatically discovers the relevant `tsconfig.json` for each\n                              file. Use this only when your project uses a non-standard tsconfig\n                              name or location.\n        --init                Initialize oxlint configuration with default values\n\nAllowing / Denying Multiple Lints\n   Accumulate rules and categories from left to right on the command-line.\n   For example `-D correctness -A no-debugger` or `-A all -D no-debugger`.\n   The categories are:\n   * `correctness` - Code that is outright wrong or useless (default)\n   * `suspicious`  - Code that is most likely wrong or useless\n   * `pedantic`    - Lints which are rather strict or have occasional false positives\n   * `perf`        - Code that could be written in a more performant way\n   * `style`       - Code that should be written in a more idiomatic way\n   * `restriction` - Lints which prevent the use of language and library features\n   * `nursery`     - New lints that are still under development\n   * `all`         - All categories listed above except `nursery`. Does not enable plugins\n  automatically.\n    -A, --allow=NAME          Allow the rule or category (suppress the lint)\n    -W, --warn=NAME           Deny the rule or category (emit a warning)\n    -D, --deny=NAME           Deny the rule or category (emit an error)\n\nEnable/Disable Plugins\n        --disable-unicorn-plugin  Disable unicorn plugin, which is turned on by default\n        --disable-oxc-plugin  Disable oxc unique rules, which is turned on by default\n        --disable-typescript-plugin  Disable TypeScript plugin, which is turned on by default\n        --import-plugin       Enable import plugin and detect ESM problems.\n        --react-plugin        Enable react plugin, which is turned off by default\n        --jsdoc-plugin        Enable jsdoc plugin and detect JSDoc problems\n        --jest-plugin         Enable the Jest plugin and detect test problems\n        --vitest-plugin       Enable the Vitest plugin and detect test problems\n        --jsx-a11y-plugin     Enable the JSX-a11y plugin and detect accessibility problems\n        --nextjs-plugin       Enable the Next.js plugin and detect Next.js problems\n        --react-perf-plugin   Enable the React performance plugin and detect rendering performance\n                              problems\n        --promise-plugin      Enable the promise plugin and detect promise usage problems\n        --node-plugin         Enable the node plugin and detect node usage problems\n        --vue-plugin          Enable the vue plugin and detect vue usage problems\n\nFix Problems\n        --fix                 Fix as many issues as possible. Only unfixed issues are reported in\n                              the output.\n        --fix-suggestions     Apply auto-fixable suggestions. May change program behavior.\n        --fix-dangerously     Apply dangerous fixes and suggestions\n\nIgnore Files\n        --ignore-path=PATH    Specify the file to use as your `.eslintignore`\n        --ignore-pattern=PAT  Specify patterns of files to ignore (in addition to those in\n                              `.eslintignore`)\n        --no-ignore           Disable excluding files from `.eslintignore` files, --ignore-path\n                              flags and --ignore-pattern flags\n\nHandle Warnings\n        --quiet               Disable reporting on warnings, only errors are reported\n        --deny-warnings       Ensure warnings produce a non-zero exit code\n        --max-warnings=INT    Specify a warning threshold, which can be used to force exit with an\n                              error status if there are too many warning-level rule violations in\n                              your project\n\nOutput\n    -f, --format=ARG          Use a specific output format. Possible values: `checkstyle`,\n                              `default`, `github`, `gitlab`, `json`, `junit`, `stylish`, `unix`\n\nMiscellaneous\n        --silent              Do not display any diagnostics\n        --threads=INT         Number of threads to use. Set to 1 for using only 1 CPU core.\n        --print-config        This option outputs the configuration to be used. When present, no\n                              linting is performed and only config-related options are valid.\n\nInline Configuration Comments\n        --report-unused-disable-directives  Report directive comments like `// oxlint-disable-line`,\n                              when no errors would have been reported on that line anyway\n        --report-unused-disable-directives-severity=SEVERITY  Same as\n                              `--report-unused-disable-directives`, but allows you to specify the\n                              severity level of the reported errors. Only one of these two options\n                              can be used at a time.\n\nAvailable positional items:\n    PATH                      Single file, single path or list of paths\n\nAvailable options:\n        --rules               List all the rules that are currently registered\n        --lsp                 Start the language server\n        --disable-nested-config  Disable the automatic loading of nested configuration files\n        --type-aware          Enable rules that require type information\n        --type-check          Enable experimental type checking (includes TypeScript compiler\n                              diagnostics)\n    -h, --help                Prints help information\n    -V, --version             Prints version information\n\n\n> vp build -h # build help message\nvp/<semver>\n\nUsage:\n  $ vp build [root]\n\nOptions:\n  --target <target>             [string] transpile target (default: 'baseline-widely-available') \n  --outDir <dir>                [string] output directory (default: dist) \n  --assetsDir <dir>             [string] directory under outDir to place assets in (default: assets) \n  --assetsInlineLimit <number>  [number] static asset base64 inline threshold in bytes (default: 4096) \n  --ssr [entry]                 [string] build specified entry for server-side rendering \n  --sourcemap [output]          [boolean | \"inline\" | \"hidden\"] output source maps for build (default: false) \n  --minify [minifier]           [boolean | \"terser\" | \"esbuild\"] enable/disable minification, or specify minifier to use (default: esbuild) \n  --manifest [name]             [boolean | string] emit build manifest json \n  --ssrManifest [name]          [boolean | string] emit ssr manifest json \n  --emptyOutDir                 [boolean] force empty outDir when it's outside of root \n  -w, --watch                   [boolean] rebuilds when modules have changed on disk \n  --app                         [boolean] same as `builder: {}` \n  -c, --config <file>           [string] use specified config file \n  --base <path>                 [string] public base path (default: /) \n  -l, --logLevel <level>        [string] info | warn | error | silent \n  --clearScreen                 [boolean] allow/disable clear screen when logging \n  --configLoader <loader>       [string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle) \n  -d, --debug [feat]            [string | boolean] show debug logs \n  -f, --filter <filter>         [string] filter debug logs \n  -m, --mode <mode>             [string] set env mode \n  -h, --help                    Display this message \n\n> vp test -h # test help message\nvp test/<semver>\n WARN: no options were found for your subcommands so we printed the whole output\n\nUsage:\n  $ vp test [...filters]\n\nCommands:\n  run [...filters]      \n  related [...filters]  \n  watch [...filters]    \n  dev [...filters]      \n  bench [...filters]    \n  init <project>        \n  list [...filters]     \n  [...filters]          \n  complete [shell]      \n\nFor more info, run any command with the `--help` flag:\n  $ vp test run --help\n  $ vp test related --help\n  $ vp test watch --help\n  $ vp test dev --help\n  $ vp test bench --help\n  $ vp test init --help\n  $ vp test list --help\n  $ vp test --help\n  $ vp test complete --help\n  $ vp test --help --expand-help\n\nOptions:\n  -v, --version                                              Display version number \n  -r, --root <path>                                          Root path \n  -c, --config <path>                                        Path to config file \n  -u, --update [type]                                        Update snapshot (accepts boolean, \"new\", \"all\" or \"none\") \n  -w, --watch                                                Enable watch mode \n  -t, --testNamePattern <pattern>                            Run tests with full names matching the specified regexp pattern \n  --dir <path>                                               Base directory to scan for the test files \n  --ui                                                       Enable UI \n  --open                                                     Open UI automatically (default: !process.env.CI) \n  --api [port]                                               Specify server port. Note if the port is already being used, Vite will automatically try the next available port so this may not be the actual port the server ends up listening on. If true will be set to 51204. Use '--help --api' for more info. \n  --silent [value]                                           Silent console output from tests. Use 'passed-only' to see logs from failing tests only. \n  --hideSkippedTests                                         Hide logs for skipped tests \n  --reporter <name>                                          Specify reporters (default, agent, blob, verbose, dot, json, tap, tap-flat, junit, tree, hanging-process, github-actions) \n  --outputFile <filename/-s>                                 Write test results to a file when supporter reporter is also specified, use cac's dot notation for individual outputs of multiple reporters (example: --outputFile.tap=./tap.txt) \n  --coverage                                                 Enable coverage report. Use '--help --coverage' for more info. \n  --mode <name>                                              Override Vite mode (default: test or benchmark) \n  --isolate                                                  Run every test file in isolation. To disable isolation, use --no-isolate (default: true) \n  --globals                                                  Inject apis globally \n  --dom                                                      Mock browser API with happy-dom \n  --browser <name>                                           Run tests in the browser. Equivalent to --browser.enabled (default: false). Use '--help --browser' for more info. \n  --pool <pool>                                              Specify pool, if not running in the browser (default: forks) \n  --execArgv <option>                                        Pass additional arguments to node process when spawning worker_threads or child_process. \n  --vmMemoryLimit <limit>                                    Memory limit for VM pools. If you see memory leaks, try to tinker this value. \n  --fileParallelism                                          Should all test files run in parallel. Use --no-file-parallelism to disable (default: true) \n  --maxWorkers <workers>                                     Maximum number or percentage of workers to run tests in \n  --environment <name>                                       Specify runner environment, if not running in the browser (default: node) \n  --passWithNoTests                                          Pass when no tests are found \n  --logHeapUsage                                             Show the size of heap for each test when running in node \n  --detectAsyncLeaks                                         Detect asynchronous resources leaking from the test file (default: false) \n  --allowOnly                                                Allow tests and suites that are marked as only (default: !process.env.CI) \n  --dangerouslyIgnoreUnhandledErrors                         Ignore any unhandled errors that occur \n  --shard <shards>                                           Test suite shard to execute in a format of <index>/<count> \n  --changed [since]                                          Run tests that are affected by the changed files (default: false) \n  --sequence <options>                                       Options for how tests should be sorted. Use '--help --sequence' for more info. \n  --inspect [[host:]port]                                    Enable Node.js inspector (default: <semver>.1:9229) \n  --inspectBrk [[host:]port]                                 Enable Node.js inspector and break before the test starts \n  --testTimeout <timeout>                                    Default timeout of a test in milliseconds (default: 5000). Use 0 to disable timeout completely. \n  --hookTimeout <timeout>                                    Default hook timeout in milliseconds (default: 10000). Use 0 to disable timeout completely. \n  --bail <number>                                            Stop test execution when given number of tests have failed (default: 0) \n  --retry <times>                                            Retry the test specific number of times if it fails (default: 0). Use '--help --retry' for more info. \n  --diff <path>                                              DiffOptions object or a path to a module which exports DiffOptions object. Use '--help --diff' for more info. \n  --exclude <glob>                                           Additional file globs to be excluded from test \n  --expandSnapshotDiff                                       Show full diff when snapshot fails \n  --disableConsoleIntercept                                  Disable automatic interception of console logging (default: false) \n  --typecheck                                                Enable typechecking alongside tests (default: false). Use '--help --typecheck' for more info. \n  --project <name>                                           The name of the project to run if you are using Vitest workspace feature. This can be repeated for multiple projects: --project=1 --project=2. You can also filter projects using wildcards like --project=packages*, and exclude projects with --project=!pattern. \n  --slowTestThreshold <threshold>                            Threshold in milliseconds for a test or suite to be considered slow (default: 300) \n  --teardownTimeout <timeout>                                Default timeout of a teardown function in milliseconds (default: 10000) \n  --cache                                                    Enable cache. Use '--help --cache' for more info. \n  --maxConcurrency <number>                                  Maximum number of concurrent tests and suites during test file execution (default: 5) \n  --expect                                                   Configuration options for expect() matches. Use '--help --expect' for more info. \n  --printConsoleTrace                                        Always print console stack traces \n  --includeTaskLocation                                      Collect test and suite locations in the location property \n  --attachmentsDir <dir>                                     The directory where attachments from context.annotate are stored in (default: .vitest-attachments) \n  --run                                                      Disable watch mode \n  --no-color                                                 Removes colors from the console output (default: true)\n  --clearScreen                                              Clear terminal screen when re-running tests during watch mode (default: true) \n  --configLoader <loader>                                    Use bundle to bundle the config with esbuild or runner (experimental) to process it on the fly. This is only available in vite version <semver> and above. (default: bundle) \n  --standalone                                               Start Vitest without running tests. Tests will be running only on change. If browser mode is enabled, the UI will be opened automatically. This option is ignored when CLI file filters are passed. (default: false) \n  --mergeReports [path]                                      Path to a blob reports directory. If this options is used, Vitest won't run any tests, it will only report previously recorded tests \n  --listTags [type]                                          List all available tags instead of running tests. --list-tags=json will output tags in JSON format, unless there are no tags. \n  --clearCache                                               Delete all Vitest caches, including experimental.fsModuleCache, without running any tests. This will reduce the performance in the subsequent test run. \n  --tagsFilter <expression>                                  Run only tests with the specified tags. You can use logical operators && (and), || (or) and ! (not) to create complex expressions, see https://vitest.dev/guide/test-tags#syntax for more information. \n  --strictTags                                               Should Vitest throw an error if test has a tag that is not defined in the config. (default: true) \n  --experimental <features>                                  Experimental features.. Use '--help --experimental' for more info. \n  -h, --help                                                 Display this message \n\n> vp preview -h # preview help message\nvp/<semver>\n\nUsage:\n  $ vp preview [root]\n\nOptions:\n  --host [host]            [string] specify hostname \n  --port <port>            [number] specify port \n  --strictPort             [boolean] exit if specified port is already in use \n  --open [path]            [boolean | string] open browser on startup \n  --outDir <dir>           [string] output directory (default: dist) \n  -c, --config <file>      [string] use specified config file \n  --base <path>            [string] public base path (default: /) \n  -l, --logLevel <level>   [string] info | warn | error | silent \n  --clearScreen            [boolean] allow/disable clear screen when logging \n  --configLoader <loader>  [string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle) \n  -d, --debug [feat]       [string | boolean] show debug logs \n  -f, --filter <filter>    [string] filter debug logs \n  -m, --mode <mode>        [string] set env mode \n  -h, --help               Display this message \n\n> vp dev -h # dev help message\nvp/<semver>\n\nUsage:\n  $ vp [root]\n\nCommands:\n  [root]           start dev server\n  build [root]     build for production\n  optimize [root]  pre-bundle dependencies (deprecated, the pre-bundle process runs automatically and does not need to be called)\n  preview [root]   locally preview production build\n\nFor more info, run any command with the `--help` flag:\n  $ vp --help\n  $ vp build --help\n  $ vp optimize --help\n  $ vp preview --help\n\nOptions:\n  --host [host]            [string] specify hostname \n  --port <port>            [number] specify port \n  --open [path]            [boolean | string] open browser on startup \n  --cors                   [boolean] enable CORS \n  --strictPort             [boolean] exit if specified port is already in use \n  --force                  [boolean] force the optimizer to ignore the cache and re-bundle \n  --experimentalBundle     [boolean] use experimental full bundle mode (this is highly experimental) \n  -c, --config <file>      [string] use specified config file \n  --base <path>            [string] public base path (default: /) \n  -l, --logLevel <level>   [string] info | warn | error | silent \n  --clearScreen            [boolean] allow/disable clear screen when logging \n  --configLoader <loader>  [string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle) \n  -d, --debug [feat]       [string | boolean] show debug logs \n  -f, --filter <filter>    [string] filter debug logs \n  -m, --mode <mode>        [string] set env mode \n  -h, --help               Display this message \n  -v, --version            Display version number \n"
  },
  {
    "path": "packages/cli/snap-tests/command-helper/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp -h # help message\",\n    \"vp pack -h # pack help message\",\n    \"vp fmt -h # fmt help message\",\n    \"vp lint -h # lint help message\",\n    \"vp build -h # build help message\",\n    \"vp test -h # test help message\",\n    \"vp preview -h # preview help message\",\n    \"vp dev -h # dev help message\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config/package.json",
    "content": "{\n  \"name\": \"command-init-inline-config\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config/snap.txt",
    "content": "> vp lint --init\nAdded 'lint' to 'vite.config.ts'.\n\n> cat vite.config.ts\nimport { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  lint: { options: { typeAware: true, typeCheck: true } },\n});\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> rm vite.config.ts\n> vp fmt --init\nAdded 'fmt' to 'vite.config.ts'.\n\n> cat vite.config.ts\nimport { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  fmt: {\n    ignorePatterns: [],\n  },\n});\n\n> cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\ncat: .oxfmtrc.json: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp lint --init\",\n    \"cat vite.config.ts\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"rm vite.config.ts\",\n    \"vp fmt --init\",\n    \"cat vite.config.ts\",\n    \"cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config-existing/package.json",
    "content": "{\n  \"name\": \"command-init-inline-config-existing\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config-existing/snap.txt",
    "content": "> vp lint --init\nSkipped initialization: 'lint' already exists in 'vite.config.ts'.\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is not created\ncat: .oxlintrc.json: No such file or directory\n\n> vp fmt --init\nSkipped initialization: 'fmt' already exists in 'vite.config.ts'.\n\n> cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is not created\ncat: .oxfmtrc.json: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config-existing/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp lint --init\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is not created\",\n    \"vp fmt --init\",\n    \"cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is not created\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-init-inline-config-existing/vite.config.ts",
    "content": "import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    rules: {},\n  },\n  fmt: {\n    ignorePatterns: [],\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests/command-install-shortcut/package.json",
    "content": "{\n  \"name\": \"command-install-shortcut\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"tslib\": \"2.8.1\"\n  },\n  \"packageManager\": \"pnpm@10.15.1\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-install-shortcut/snap.txt",
    "content": "> vp run install # install shortcut\n$ vp install\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ tslib <semver>\n\nDone in <variable>ms using pnpm v<semver>\n\n---\nvp run: command-install-shortcut#install not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> vp run install # install shortcut hit cache\n$ vp install\nLockfile is up to date, resolution step is skipped\nAlready up to date\n\nDone in <variable>ms using pnpm v<semver>\n\n---\nvp run: command-install-shortcut#install not cached because it modified its input. (Run `vp run --last-details` for full details)\n"
  },
  {
    "path": "packages/cli/snap-tests/command-install-shortcut/steps.json",
    "content": "{\n  \"commands\": [\"vp run install # install shortcut\", \"vp run install # install shortcut hit cache\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-install-shortcut/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n    tasks: {\n      install: {\n        command: 'vp install',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack/package.json",
    "content": "{\n  \"name\": \"command-pack\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack/snap.txt",
    "content": "> vp pack -h # should print the help message\nvp pack\n\nUsage:\n  $ vp pack [...files]\n\nCommands:\n  [...files]  Bundle files\n\nFor more info, run any command with the `--help` flag:\n  $ vp pack --help\n\nOptions:\n  --config-loader <loader>      Config loader to use: auto, native, unrun (default: auto)\n  --no-config                   Disable config file (default: true)\n  -f, --format <format>         Bundle format: esm, cjs, iife, umd (default: esm)\n  --clean                       Clean output directory, --no-clean to disable \n  --deps.never-bundle <module>  Mark dependencies as external \n  --minify                      Minify output \n  --devtools                    Enable devtools integration \n  --debug [feat]                Show debug logs \n  --target <target>             Bundle target, e.g \"es2015\", \"esnext\" \n  -l, --logLevel <level>        Set log level: info, warn, error, silent \n  --fail-on-warn                Fail on warnings (default: true)\n  --no-write                    Disable writing files to disk, incompatible with watch mode (default: true)\n  -d, --out-dir <dir>           Output directory (default: dist)\n  --treeshake                   Tree-shake bundle (default: true)\n  --sourcemap                   Generate source map (default: false)\n  --shims                       Enable cjs and esm shims (default: false)\n  --platform <platform>         Target platform (default: node)\n  --dts                         Generate dts files \n  --publint                     Enable publint (default: false)\n  --attw                        Enable Are the types wrong integration (default: false)\n  --unused                      Enable unused dependencies check (default: false)\n  -w, --watch [path]            Watch mode \n  --ignore-watch <path>         Ignore custom paths in watch mode \n  --from-vite [vitest]          Reuse config from Vite or Vitest \n  --report                      Size report (default: true)\n  --env.* <value>               Define compile-time env variables \n  --env-file <file>             Load environment variables from a file, when used together with --env, variables in --env take precedence \n  --env-prefix <prefix>         Prefix for env variables to inject into the bundle (default: VITE_PACK_,TSDOWN_)\n  --on-success <command>        Command to run on success \n  --copy <dir>                  Copy files to output dir \n  --public-dir <dir>            Alias for --copy, deprecated \n  --tsconfig <tsconfig>         Set tsconfig path \n  --unbundle                    Unbundle mode \n  --root <dir>                  Root directory of input files \n  --exe                         Bundle as executable \n  -W, --workspace [dir]         Enable workspace mode \n  -F, --filter <pattern>        Filter configs (cwd or name), e.g. /pkg-name$/ or pkg-name \n  --exports                     Generate export-related metadata for package.json (experimental) \n  -h, --help                    Display this message \n\n> vp run pack # should build the library\n$ vp pack src/index.ts\nℹ entry: src/index.ts\nℹ Build start\nℹ dist/index.mjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\n\n---\nvp run: command-pack#pack not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> ls dist # should have the library\nindex.mjs\n\n> vp run pack # should hit cache\n$ vp pack src/index.ts\nℹ entry: src/index.ts\nℹ Build start\nℹ Cleaning 1 files\nℹ dist/index.mjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\n\n---\nvp run: command-pack#pack not cached because it modified its input. (Run `vp run --last-details` for full details)\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack/src/hello.ts",
    "content": "export function hello() {\n  console.log('Hello tsdown!');\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack/src/index.ts",
    "content": "import { hello } from './hello.ts';\n\nhello();\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pack -h # should print the help message\",\n    \"vp run pack # should build the library\",\n    \"ls dist # should have the library\",\n    \"vp run pack # should hit cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      pack: {\n        command: 'vp pack src/index.ts',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-external/package.json",
    "content": "{\n  \"name\": \"command-pack-external\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-external/snap.txt",
    "content": "> vp pack --deps.never-bundle node:path src/index.ts # should bundle with deps.never-bundle flag\nℹ entry: src/index.ts\nℹ Build start\nℹ dist/index.mjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\n\n> vp pack --external node:path src/index.ts # should bundle with legacy external flag\nℹ entry: src/index.ts\nwarn: `external` is deprecated. Use `deps.neverBundle` instead.\nℹ Build start\nℹ Cleaning 1 files\nℹ dist/index.mjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-external/src/index.ts",
    "content": "import path from 'node:path';\n\nconsole.log(path.join('a', 'b'));\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-external/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pack --deps.never-bundle node:path src/index.ts # should bundle with deps.never-bundle flag\",\n    \"vp pack --external node:path src/index.ts # should bundle with legacy external flag\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/package.json",
    "content": "{\n  \"name\": \"command-pack-monorepo\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/array-config/package.json",
    "content": "{\n  \"name\": \"array-config\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"main\": \"src/index.ts\",\n  \"scripts\": {\n    \"build\": \"vp pack\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/array-config/src/sub/hello.ts",
    "content": "export function hello() {\n  console.log('Hello tsdown!');\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/array-config/src/sub/index.ts",
    "content": "import { hello } from './hello.ts';\n\nhello();\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/array-config/vite.config.ts",
    "content": "export default {\n  pack: [\n    {\n      entry: ['./src/sub/index.ts'],\n      clean: true,\n      format: ['esm'],\n      minify: false,\n      dts: true,\n      outDir: './dist',\n    },\n  ],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/default-config/package.json",
    "content": "{\n  \"name\": \"default-config\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"main\": \"src/index.ts\",\n  \"scripts\": {\n    \"build\": \"vp pack\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/default-config/src/hello.ts",
    "content": "export function hello() {\n  console.log('Hello tsdown!');\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/default-config/src/index.ts",
    "content": "import { hello } from './hello.ts';\n\nhello();\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/hello/package.json",
    "content": "{\n  \"name\": \"hello\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"main\": \"src/index.ts\",\n  \"scripts\": {\n    \"build\": \"vp pack\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/hello/src/hello.ts",
    "content": "export function hello() {\n  console.log('Hello tsdown!');\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/hello/src/index.ts",
    "content": "import { hello } from './hello.ts';\n\nhello();\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/packages/hello/vite.config.ts",
    "content": "export default {\n  pack: {\n    entry: 'src/index.ts',\n    format: ['cjs'],\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/snap.txt",
    "content": "> vp run hello#build # should build the library\n> ls packages/hello/dist # should have the library\nindex.cjs\n\n> vp run hello#build 2>&1 # should hit cache but not working for now\n~/packages/hello$ vp pack\nℹ entry: src/index.ts\nℹ Build start\nℹ Cleaning 1 files\nℹ dist/index.cjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\n\n---\nvp run: hello#build not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> vp run array-config#build # should build the library supports array config\n> ls packages/array-config/dist # should have the library\nindex.d.mts\nindex.mjs\n\n> vp run array-config#build 2>&1 # should hit cache but not working\n~/packages/array-config$ vp pack\nℹ entry: src/sub/index.ts\nℹ Build start\nℹ Cleaning 2 files\nℹ dist/index.mjs    <variable> kB │ gzip: <variable> kB\nℹ dist/index.d.mts  <variable> kB │ gzip: <variable> kB\nℹ 2 files, total: <variable> kB\n✔ Build complete in <variable>ms\n\n---\nvp run: array-config#build not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> vp run default-config#build # should build the library supports default config\n> ls packages/default-config/dist # should have the library\nindex.mjs\n\n> vp run default-config#build 2>&1 # should hit cache but not working\n~/packages/default-config$ vp pack\nℹ entry: src/index.ts\nℹ Build start\nℹ Cleaning 1 files\nℹ dist/index.mjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\n\n---\nvp run: default-config#build not cached because it modified its input. (Run `vp run --last-details` for full details)\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    {\n      \"command\": \"vp run hello#build # should build the library\",\n      \"ignoreOutput\": true\n    },\n    \"ls packages/hello/dist # should have the library\",\n    \"vp run hello#build 2>&1 # should hit cache but not working for now\",\n    {\n      \"command\": \"vp run array-config#build # should build the library supports array config\",\n      \"ignoreOutput\": true\n    },\n    \"ls packages/array-config/dist # should have the library\",\n    \"vp run array-config#build 2>&1 # should hit cache but not working\",\n    {\n      \"command\": \"vp run default-config#build # should build the library supports default config\",\n      \"ignoreOutput\": true\n    },\n    \"ls packages/default-config/dist # should have the library\",\n    \"vp run default-config#build 2>&1 # should hit cache but not working\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-monorepo/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-no-input/package.json",
    "content": "{\n  \"name\": \"command-pack-no-input\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-no-input/snap.txt",
    "content": "[1]> vp pack # should not mention tsdown in error\nerror: No input files, try \"vp pack <your-file>\" or create src/index.ts\n"
  },
  {
    "path": "packages/cli/snap-tests/command-pack-no-input/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp pack # should not mention tsdown in error\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-preview/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-preview/snap.txt",
    "content": "> vp preview --port 12312312312 2>&1 | grep -E '(RangeError|No available ports)' # intentionally use an invalid port (exceeds 0-65535) to trigger port error\nError: No available ports found between 12312312312 and 65535\n"
  },
  {
    "path": "packages/cli/snap-tests/command-preview/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp preview --port 12312312312 2>&1 | grep -E '(RangeError|No available ports)' # intentionally use an invalid port (exceeds 0-65535) to trigger port error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-run-with-vp-config/package.json",
    "content": "{\n  \"name\": \"command-run-with-vp-config\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"foo\": \"vp config\",\n    \"bar\": \"vp not-exist-command\"\n  },\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-run-with-vp-config/snap.txt",
    "content": "> vp run foo # should run vp config command\n$ vp config ⊘ cache disabled\n.git can't be found\n\n\n[2]> vp run bar # should throw error\n$ vp not-exist-command ⊘ cache disabled\nerror: Command 'not-exist-command' not found\n\nDid you mean `vp test`?\n\n"
  },
  {
    "path": "packages/cli/snap-tests/command-run-with-vp-config/steps.json",
    "content": "{\n  \"commands\": [\"vp run foo # should run vp config command\", \"vp run bar # should throw error\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-version/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-version/snap.txt",
    "content": "> vp --version\nVITE+ - The Unified Toolchain for the Web\n\nvp v<semver>\n\nLocal vite-plus:\n  vite-plus  Not found\n\n"
  },
  {
    "path": "packages/cli/snap-tests/command-version/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp --version\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-vp-alias/package.json",
    "content": "{\n  \"name\": \"command-vp-alias\",\n  \"version\": \"0.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/command-vp-alias/snap.txt",
    "content": "> vp -h # vp should show help same as vite\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp <COMMAND>\n\nCore Commands:\n  dev            Run the development server\n  build          Build for production\n  test           Run tests\n  lint           Lint code\n  fmt            Format code\n  check          Run format, lint, and type checks\n  pack           Build library\n  run            Run tasks\n  exec           Execute a command from local node_modules/.bin\n  preview        Preview production build\n  cache          Manage the task cache\n  config         Configure hooks and agent integration\n  staged         Run linters on staged files\n\nPackage Manager Commands:\n  install    Install all dependencies, or add packages if package names are provided\n\nOptions:\n  -h, --help  Print help\n\n> vp run -h # vp run should show help\nRun tasks\n\nUsage: vp run [OPTIONS] [TASK_SPECIFIER] [ADDITIONAL_ARGS]...\n\nArguments:\n  [TASK_SPECIFIER]      `packageName#taskName` or `taskName`. If omitted, lists all available tasks\n  [ADDITIONAL_ARGS]...  Additional arguments to pass to the tasks\n\nOptions:\n  -r, --recursive          Select all packages in the workspace\n  -t, --transitive         Select the current package and its transitive dependencies\n  -w, --workspace-root     Select the workspace root package\n  -F, --filter <FILTERS>   Match packages by name, directory, or glob pattern\n      --ignore-depends-on  Do not run dependencies specified in `dependsOn` fields\n  -v, --verbose            Show full detailed summary after execution\n      --cache              Force caching on for all tasks and scripts\n      --no-cache           Force caching off for all tasks and scripts\n      --log <LOG>          How task output is displayed [default: interleaved] [possible values: interleaved, labeled, grouped]\n      --last-details       Display the detailed summary of the last run\n  -h, --help               Print help (see more with '--help')\n"
  },
  {
    "path": "packages/cli/snap-tests/command-vp-alias/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"linux\"],\n  \"commands\": [\"vp -h # vp should show help same as vite\", \"vp run -h # vp run should show help\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/exit-code/failure.js",
    "content": "console.log('failure');\nprocess.exit(1);\n"
  },
  {
    "path": "packages/cli/snap-tests/exit-code/package.json",
    "content": "{\n  \"scripts\": {\n    \"script1\": \"echo 'success'\",\n    \"script2\": \"node failure.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/exit-code/snap.txt",
    "content": "> vp run script1 # script1 run, create the cache and should be success\n$ echo 'success' ⊘ cache disabled\nsuccess\n\n\n> vp run script1 # script1 should hit the updated cache\n$ echo 'success' ⊘ cache disabled\nsuccess\n\n\n[1]> vp run script2 # script2 should be failure and not cache\n$ node failure.js\nfailure\n\n\n[1]> vp run script2 # script2 should be failure and not cache\n$ node failure.js\nfailure\n\n"
  },
  {
    "path": "packages/cli/snap-tests/exit-code/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp run script1 # script1 run, create the cache and should be success\",\n    \"vp run script1 # script1 should hit the updated cache\",\n    \"vp run script2 # script2 should be failure and not cache\",\n    \"vp run script2 # script2 should be failure and not cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/exit-code/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/fingerprint-ignore-test/package.json",
    "content": "{\n  \"name\": \"@test/fingerprint-ignore\",\n  \"version\": \"1.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/fingerprint-ignore-test/snap.txt",
    "content": "> vp run create-files # first run\n$ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    Vite+ Task Runner • Execution Summary\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nStatistics:   1 tasks • 0 cache hits • 1 cache misses \nPerformance:  0% cache hit rate\n\nTask Details:\n────────────────────────────────────────────────\n  [1] @test/fingerprint-ignore#create-files: $ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js ✓\n      → Cache miss: no previous cache entry found\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n> vp run create-files # cache hit - no changes\n$ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js (✓ cache hit, replaying)\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    Vite+ Task Runner • Execution Summary\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nStatistics:   1 tasks • 1 cache hits • 0 cache misses \nPerformance:  100% cache hit rate, <variable>ms saved in total\n\nTask Details:\n────────────────────────────────────────────────\n  [1] @test/fingerprint-ignore#create-files: $ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js ✓\n      → Cache hit - output replayed - <variable>ms saved\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n> echo 'module.exports = {modified: true}' > node_modules/pkg-a/index.js\n> vp run create-files # cache hit - index.js ignored\n$ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js (✓ cache hit, replaying)\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    Vite+ Task Runner • Execution Summary\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nStatistics:   1 tasks • 1 cache hits • 0 cache misses \nPerformance:  100% cache hit rate, <variable>ms saved in total\n\nTask Details:\n────────────────────────────────────────────────\n  [1] @test/fingerprint-ignore#create-files: $ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js ✓\n      → Cache hit - output replayed - <variable>ms saved\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n> echo 'modified output' > dist/bundle.js\n> vp run create-files # cache hit - dist ignored\n$ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js (✓ cache hit, replaying)\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    Vite+ Task Runner • Execution Summary\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nStatistics:   1 tasks • 1 cache hits • 0 cache misses \nPerformance:  100% cache hit rate, <variable>ms saved in total\n\nTask Details:\n────────────────────────────────────────────────\n  [1] @test/fingerprint-ignore#create-files: $ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js ✓\n      → Cache hit - output replayed - <variable>ms saved\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n> echo '{\"name\":\"pkg-a\",\"version\":\"2.0.0\"}' > node_modules/pkg-a/package.json\n> vp run create-files # cache miss - package.json NOT ignored\n$ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js (✗ cache miss: 'node_modules/pkg-a/package.json' modified, executing)\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    Vite+ Task Runner • Execution Summary\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nStatistics:   1 tasks • 0 cache hits • 1 cache misses \nPerformance:  0% cache hit rate\n\nTask Details:\n────────────────────────────────────────────────\n  [1] @test/fingerprint-ignore#create-files: $ mkdir -p node_modules/pkg-a dist && echo '{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}' > node_modules/pkg-a/package.json && echo 'module.exports = {}' > node_modules/pkg-a/index.js && echo 'output' > dist/bundle.js ✓\n      → Cache miss: 'node_modules/pkg-a/package.json' modified\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
  },
  {
    "path": "packages/cli/snap-tests/fingerprint-ignore-test/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"linux\", \"win32\", \"darwin\"],\n  \"_comment\": \"TEMPORARILY disabled on darwin - fingerprintIgnores not yet supported in vite-task user config\",\n  \"commands\": [\n    \"vp run create-files # first run\",\n    \"vp run create-files # cache hit - no changes\",\n    \"echo 'module.exports = {modified: true}' > node_modules/pkg-a/index.js\",\n    \"vp run create-files # cache hit - index.js ignored\",\n    \"echo 'modified output' > dist/bundle.js\",\n    \"vp run create-files # cache hit - dist ignored\",\n    \"echo '{\\\"name\\\":\\\"pkg-a\\\",\\\"version\\\":\\\"2.0.0\\\"}' > node_modules/pkg-a/package.json\",\n    \"vp run create-files # cache miss - package.json NOT ignored\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/fingerprint-ignore-test/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      'create-files': {\n        command:\n          'mkdir -p node_modules/pkg-a dist && echo \\'{\"name\":\"pkg-a\",\"version\":\"1.0.0\"}\\' > node_modules/pkg-a/package.json && echo \\'module.exports = {}\\' > node_modules/pkg-a/index.js && echo \\'output\\' > dist/bundle.js',\n        cache: true,\n        fingerprintIgnores: ['node_modules/**/*', '!node_modules/**/package.json', 'dist/**/*'],\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-check-with-vite-config/package.json",
    "content": "{\n  \"name\": \"@test/fmt-check-with-vite-config\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-check-with-vite-config/snap.txt",
    "content": "[1]> vp fmt --check # Test that vp fmt --check uses vite.config.ts\nChecking formatting...\nsrc/valid.js (<variable>ms)\n\nFormat issues found in above 1 files. Run without `--check` to fix.\nFinished in <variable>ms on 4 files using <variable> threads.\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-check-with-vite-config/src/valid.js",
    "content": "// Properly formatted file\nfunction example() {\n  return 'hello';\n}\n\nexport { example };\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-check-with-vite-config/steps.json",
    "content": "{\n  \"commands\": [\"vp fmt --check # Test that vp fmt --check uses vite.config.ts\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-check-with-vite-config/vite.config.ts",
    "content": "export default {\n  fmt: {\n    indentWidth: 4,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-ignore-patterns/package.json",
    "content": "{\n  \"name\": \"@test/fmt-ignore-patterns\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-ignore-patterns/snap.txt",
    "content": "> vp fmt src/ # Test that fmt ignorePatterns works - ignored files should not be formatted\nFinished in <variable>ms on 1 files using <variable> threads.\n\n> cat src/ignored/badly-formatted.js # Verify that ignored file still has bad formatting (was not formatted)\n// This file has bad formatting but should be ignored due to ignorePatterns\nfunction   badlyFormatted(    ){\nreturn    'hello'   ;   }\nexport{badlyFormatted}\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-ignore-patterns/src/ignored/badly-formatted.js",
    "content": "// This file has bad formatting but should be ignored due to ignorePatterns\nfunction   badlyFormatted(    ){\nreturn    'hello'   ;   }\nexport{badlyFormatted}\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-ignore-patterns/src/valid.js",
    "content": "// This file should be formatted and pass the check\nfunction validCode() {\n  return 'hello';\n}\n\nexport { validCode };\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-ignore-patterns/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp fmt src/ # Test that fmt ignorePatterns works - ignored files should not be formatted\",\n    \"cat src/ignored/badly-formatted.js # Verify that ignored file still has bad formatting (was not formatted)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/fmt-ignore-patterns/vite.config.ts",
    "content": "export default {\n  fmt: {\n    ignorePatterns: ['src/ignored/**/*'],\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/ignore_dist/.gitignore",
    "content": "# Prevent oxlint from scanning node_modules.\n# Without this, oxlint reads files in node_modules/.vite/task-cache, causing\n# fspy to fingerprint the cache directory as an input. The cache directory\n# changes between runs (DB writes, last-summary.json), producing flaky\n# cache-miss reasons that alternate between '' and\n# 'node_modules/.vite/task-cache'.\nnode_modules\n"
  },
  {
    "path": "packages/cli/snap-tests/ignore_dist/package.json",
    "content": "{\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/ignore_dist/snap.txt",
    "content": "> vp run lint\n$ vp lint\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n\n> mkdir dist\n> vp run lint # new dist folder should not invalidate cache\n$ vp lint ◉ cache hit, replaying\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n---\nvp run: cache hit, <variable>ms saved.\n"
  },
  {
    "path": "packages/cli/snap-tests/ignore_dist/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp run lint\",\n    \"mkdir dist\",\n    \"vp run lint # new dist folder should not invalidate cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/ignore_dist/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      lint: {\n        command: 'vp lint',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-ignore-patterns/package.json",
    "content": "{\n  \"name\": \"@test/lint-ignore-patterns\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-ignore-patterns/snap.txt",
    "content": "> vp lint src/ # Test that lint ignorePatterns works - ignored files should not be linted\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-ignore-patterns/src/ignored/has-error.js",
    "content": "// This file has lint errors but should be ignored due to ignorePatterns\n// These would trigger lint warnings/errors if not ignored\nvar x = 1;\nconsole.log(x);\neval('bad code');\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-ignore-patterns/src/valid.js",
    "content": "// This file should be linted and pass\nfunction validCode() {\n  return 'hello';\n}\n\nexport { validCode };\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-ignore-patterns/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp lint src/ # Test that lint ignorePatterns works - ignored files should not be linted\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-ignore-patterns/vite.config.ts",
    "content": "export default {\n  lint: {\n    ignorePatterns: ['src/ignored/**/*'],\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-vite-config-rules/package.json",
    "content": "{\n  \"name\": \"@test/lint-vite-config-rules\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-vite-config-rules/snap.txt",
    "content": "> vp lint # Test that vp lint reads rules from vite.config.ts\n\n  ⚠ eslint(no-console): Unexpected console statement.\n   ╭─[src/has-console.js:3:3]\n 2 │ function example() {\n 3 │   console.log('hello');\n   ·   ───────────\n 4 │   return 'hello';\n   ╰────\n  help: Delete this console statement.\n\nFound 1 warning and 0 errors.\nFinished in <variable>ms on 3 files with <variable> rules using <variable> threads.\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-vite-config-rules/src/has-console.js",
    "content": "// This file has console.log to trigger no-console rule from vite.config.ts\nfunction example() {\n  console.log('hello');\n  return 'hello';\n}\n\nexport { example };\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-vite-config-rules/src/valid.js",
    "content": "// Clean file with no lint errors\nfunction example() {\n  return 'hello';\n}\n\nexport { example };\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-vite-config-rules/steps.json",
    "content": "{\n  \"commands\": [\"vp lint # Test that vp lint reads rules from vite.config.ts\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/lint-vite-config-rules/vite.config.ts",
    "content": "export default {\n  lint: {\n    rules: {\n      'no-console': 'warn',\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/npm-install-with-options/package.json",
    "content": "{\n  \"name\": \"npm-install-with-options\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"tslib\": \"2.8.1\"\n  },\n  \"devDependencies\": {\n    \"oxlint\": \"latest\"\n  },\n  \"packageManager\": \"npm@10.9.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/npm-install-with-options/snap.txt",
    "content": "> vp install --help # print help message\nInstall a package\n\nUsage:\nnpm install [<package-spec> ...]\n\nOptions:\n[-S|--save|--no-save|--save-prod|--save-dev|--save-optional|--save-peer|--save-bundle]\n[-E|--save-exact] [-g|--global]\n[--install-strategy <hoisted|nested|shallow|linked>] [--legacy-bundling]\n[--global-style] [--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]\n[--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]\n[--strict-peer-deps] [--prefer-dedupe] [--no-package-lock] [--package-lock-only]\n[--foreground-scripts] [--ignore-scripts] [--no-audit] [--no-bin-links]\n[--no-fund] [--dry-run] [--cpu <cpu>] [--os <os>] [--libc <libc>]\n[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]\n[-ws|--workspaces] [--include-workspace-root] [--install-links]\n\naliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall\n\nRun \"npm help install\" for more info\n\n> vp run install # https://docs.npmjs.com/cli/v10/commands/npm-install\n$ vp install --production --silent\n\n---\nvp run: npm-install-with-options#install not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> ls node_modules\n@oxlint\ntslib\n\n> vp run install # install again hit cache\n$ vp install --production --silent\n\n---\nvp run: npm-install-with-options#install not cached because it modified its input. (Run `vp run --last-details` for full details)\n"
  },
  {
    "path": "packages/cli/snap-tests/npm-install-with-options/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp install --help # print help message\",\n    \"vp run install # https://docs.npmjs.com/cli/v10/commands/npm-install\",\n    \"ls node_modules\",\n    \"vp run install # install again hit cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/npm-install-with-options/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n    tasks: {\n      install: {\n        command: 'vp install --production --silent',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/.gitignore",
    "content": "# Prevent oxlint from scanning node_modules.\n# Without this, oxlint reads files in node_modules/.vite/task-cache, causing\n# fspy to fingerprint the cache directory as an input. The cache directory\n# changes between runs (DB writes, last-summary.json), producing flaky\n# cache-miss reasons that alternate between '' and\n# 'node_modules/.vite/task-cache'.\nnode_modules\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/snap.txt",
    "content": "> vp run lint\n$ vp lint ./src\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n\n> echo //comment >> types.ts\n> vp run lint # non-type-aware linting doesn't read types.ts\n$ vp lint ./src ◉ cache hit, replaying\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n---\nvp run: cache hit, <variable>ms saved.\n\n> vp run lint-typeaware\n$ vp lint --type-aware ./src\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n\n> echo //comment >> types.ts\n> vp run lint-typeaware # type-aware linting reads types.ts\n$ vp lint --type-aware ./src ○ cache miss: 'types.ts' modified, executing\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/src/index.ts",
    "content": "import type { Foo } from '../types';\n\ndeclare const _foo: Foo;\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"\"],\n  \"commands\": [\n    \"vp run lint\",\n    \"echo //comment >> types.ts\",\n    \"vp run lint # non-type-aware linting doesn't read types.ts\",\n    \"vp run lint-typeaware\",\n    \"echo //comment >> types.ts\",\n    \"vp run lint-typeaware # type-aware linting reads types.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/types.ts",
    "content": "export type Foo = number;\n"
  },
  {
    "path": "packages/cli/snap-tests/oxlint-typeaware/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      lint: {\n        command: 'vp lint ./src',\n      },\n      'lint-typeaware': {\n        command: 'vp lint --type-aware ./src',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/pass-no-color-env/check.js",
    "content": "console.log('NO_COLOR=%s, CI=%s', process.env.NO_COLOR, process.env.CI);\n"
  },
  {
    "path": "packages/cli/snap-tests/pass-no-color-env/package.json",
    "content": "{\n  \"scripts\": {\n    \"check\": \"node check.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/pass-no-color-env/snap.txt",
    "content": "> vp run check -- --foo # get NO_COLOR=true from default env\n$ node check.js --foo\nNO_COLOR=true, CI=true\n\n\n> NO_COLOR=false vp run check -- --bar # get NO_COLOR=false from custom env\n$ node check.js --bar\nNO_COLOR=false, CI=true\n\n"
  },
  {
    "path": "packages/cli/snap-tests/pass-no-color-env/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp run check -- --foo # get NO_COLOR=true from default env\",\n    \"NO_COLOR=false vp run check -- --bar # get NO_COLOR=false from custom env\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/pass-no-color-env/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/hello.mjs",
    "content": "import fs from 'node:fs';\n\nconsole.log(fs.readFileSync('input.txt', 'utf8').trim(), process.env.FOO);\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/input.txt",
    "content": "input_content\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/package.json",
    "content": "{\n  \"name\": \"root-package\",\n  \"scripts\": {\n    \"my-task\": \"echo foo && vp lint\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/snap.txt",
    "content": "> FOO=1 vp run hello\n$ node hello.mjs\ninput_content 1\n\n\n> FOO=1 vp run hello # hit cache\n$ node hello.mjs ◉ cache hit, replaying\ninput_content 1\n\n---\nvp run: cache hit, <variable>ms saved.\n\n> FOO=2 vp run hello # env changed\n$ node hello.mjs ○ cache miss: envs changed, executing\ninput_content 2\n\n\n> FOO=2 BAR=1 vp run hello # env added\n$ node hello.mjs ○ cache miss: envs changed, executing\ninput_content 2\n\n\n> vp run hello # env removed\n$ node hello.mjs ○ cache miss: envs changed, executing\ninput_content undefined\n\n\n> echo bar > input.txt\n> vp run hello # input changed\n$ node hello.mjs ○ cache miss: 'input.txt' modified, executing\nbar undefined\n\n\n> # set untrackedEnv via env var\n> VITE_TASK_PASS_THROUGH_ENVS=PTE vp run hello # untrackedEnv changed\n$ node hello.mjs ○ cache miss: untracked env config changed, executing\nbar undefined\n\n\n> # set cwd via env var (keep untrackedEnv from previous step)\n> VITE_TASK_PASS_THROUGH_ENVS=PTE VITE_TASK_CWD=subfolder vp run hello # cwd changed\n~/subfolder$ node hello.mjs ○ cache miss: working directory changed, executing\nhello from subfolder\n\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_PLUS_CLI_TEST\": \"0\"\n  },\n  \"commands\": [\n    \"FOO=1 vp run hello\",\n    \"FOO=1 vp run hello # hit cache\",\n    \"FOO=2 vp run hello # env changed\",\n    \"FOO=2 BAR=1 vp run hello # env added\",\n    \"vp run hello # env removed\",\n    \"echo bar > input.txt\",\n    \"vp run hello # input changed\",\n    \"# set untrackedEnv via env var\",\n    \"VITE_TASK_PASS_THROUGH_ENVS=PTE vp run hello # untrackedEnv changed\",\n    \"# set cwd via env var (keep untrackedEnv from previous step)\",\n    \"VITE_TASK_PASS_THROUGH_ENVS=PTE VITE_TASK_CWD=subfolder vp run hello # cwd changed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/subfolder/hello.mjs",
    "content": "console.log('hello from subfolder');\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui/vite.config.ts",
    "content": "const untrackedEnv = process.env.VITE_TASK_PASS_THROUGH_ENVS?.split(',');\nconst cwd = process.env.VITE_TASK_CWD;\n\nexport default {\n  run: {\n    tasks: {\n      hello: {\n        command: 'node hello.mjs',\n        env: ['FOO', 'BAR'],\n        cache: true,\n        ...(untrackedEnv && { untrackedEnv }),\n        ...(cwd && { cwd }),\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/.gitignore",
    "content": "# Prevent oxlint from scanning node_modules.\n# Without this, oxlint reads files in node_modules/.vite/task-cache, causing\n# fspy to fingerprint the cache directory as an input. The cache directory\n# changes between runs (DB writes, last-summary.json), producing flaky\n# cache-miss reasons that alternate between '' and\n# 'node_modules/.vite/task-cache'.\nnode_modules\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/a.ts",
    "content": "console.log('a');\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/package.json",
    "content": "{\n  \"name\": \"root-package\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"hello\": \"vp lint ./src && vp lint\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/snap.txt",
    "content": "> vp run hello\n$ vp lint ./src\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n$ vp lint\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 3 files with <variable> rules using <variable> threads.\n\n---\nvp run: 0/2 cache hit (0%). (Run `vp run --last-details` for full details)\n\n> echo 'console.log(123)' > a.ts\n> vp run hello # report cache status from the inner runner\n$ vp lint ./src ◉ cache hit, replaying\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 1 file with <variable> rules using <variable> threads.\n\n$ vp lint ○ cache miss: 'a.ts' modified, executing\nFound 0 warnings and 0 errors.\nFinished in <variable>ms on 3 files with <variable> rules using <variable> threads.\n\n---\nvp run: 1/2 cache hit (50%), <variable>ms saved. (Run `vp run --last-details` for full details)\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/src/index.ts",
    "content": "alert('hello');\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_PLUS_CLI_TEST\": \"0\"\n  },\n  \"commands\": [\n    \"vp run hello\",\n    \"echo 'console.log(123)' > a.ts\",\n    \"vp run hello # report cache status from the inner runner\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/plain-terminal-ui-nested/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/run-task-command-conflict/package.json",
    "content": "{\n  \"name\": \"run-task-command-conflict-test\",\n  \"private\": true,\n  \"scripts\": {\n    \"build\": \"echo 'build from package.json'\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/run-task-command-conflict/snap.txt",
    "content": "> # Test that conflicting package.json and vite.config.ts task commands render cleanly\n[1]> vp run build\nerror: Failed to load task graph\n* Task run-task-command-conflict-test#build conflicts with a package.json script of the same name. Remove the script from package.json or rename the task\n"
  },
  {
    "path": "packages/cli/snap-tests/run-task-command-conflict/steps.json",
    "content": "{\n  \"commands\": [\n    \"# Test that conflicting package.json and vite.config.ts task commands render cleanly\",\n    \"vp run build\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/run-task-command-conflict/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      build: {\n        command: \"echo 'build from vite.config.ts'\",\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-build-cache-disabled/index.html",
    "content": "<!doctype html>\n<html>\n  <body>\n    <script type=\"module\">\n      console.log('hello');\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-build-cache-disabled/package.json",
    "content": "{\n  \"name\": \"synthetic-build-cache-disabled-test\",\n  \"scripts\": {\n    \"build\": \"vp build\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-build-cache-disabled/snap.txt",
    "content": "> vp run build # synthetic build (vp build) should have cache disabled without cacheScripts\n$ vp build ⊘ cache disabled\nvite v<semver> building client environment for production...\ntransforming...✓ <variable> modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html                <variable> kB │ gzip: <variable> kB\ndist/assets/index-BnIqjoTZ.js  <variable> kB │ gzip: <variable> kB\n\n✓ built in <variable>ms\n\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-build-cache-disabled/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp run build # synthetic build (vp build) should have cache disabled without cacheScripts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-dev-cache-disabled/package.json",
    "content": "{\n  \"name\": \"synthetic-dev-cache-disabled-test\",\n  \"scripts\": {\n    \"dev\": \"vp dev --help\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-dev-cache-disabled/snap.txt",
    "content": "> vp run dev # synthetic dev (vp dev) should have cache disabled even with cacheScripts\n$ vp dev --help ⊘ cache disabled\nvp/<semver>\n\nUsage:\n  $ vp [root]\n\nCommands:\n  [root]           start dev server\n  build [root]     build for production\n  optimize [root]  pre-bundle dependencies (deprecated, the pre-bundle process runs automatically and does not need to be called)\n  preview [root]   locally preview production build\n\nFor more info, run any command with the `--help` flag:\n  $ vp --help\n  $ vp build --help\n  $ vp optimize --help\n  $ vp preview --help\n\nOptions:\n  --host [host]            [string] specify hostname \n  --port <port>            [number] specify port \n  --open [path]            [boolean | string] open browser on startup \n  --cors                   [boolean] enable CORS \n  --strictPort             [boolean] exit if specified port is already in use \n  --force                  [boolean] force the optimizer to ignore the cache and re-bundle \n  --experimentalBundle     [boolean] use experimental full bundle mode (this is highly experimental) \n  -c, --config <file>      [string] use specified config file \n  --base <path>            [string] public base path (default: /) \n  -l, --logLevel <level>   [string] info | warn | error | silent \n  --clearScreen            [boolean] allow/disable clear screen when logging \n  --configLoader <loader>  [string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle) \n  -d, --debug [feat]       [string | boolean] show debug logs \n  -f, --filter <filter>    [string] filter debug logs \n  -m, --mode <mode>        [string] set env mode \n  -h, --help               Display this message \n  -v, --version            Display version number \n\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-dev-cache-disabled/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp run dev # synthetic dev (vp dev) should have cache disabled even with cacheScripts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/synthetic-dev-cache-disabled/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/task-config-cwd/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests/task-config-cwd/snap.txt",
    "content": "> vp run hello\n~/subfolder$ node a.js\nhello from subfolder\n\n"
  },
  {
    "path": "packages/cli/snap-tests/task-config-cwd/steps.json",
    "content": "{\n  \"commands\": [\"vp run hello\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/task-config-cwd/subfolder/a.js",
    "content": "console.log('hello from subfolder');\n"
  },
  {
    "path": "packages/cli/snap-tests/task-config-cwd/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      hello: {\n        command: 'node a.js',\n        cwd: 'subfolder',\n        cache: true,\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/test-nested-tasks/package.json",
    "content": "{\n  \"scripts\": {\n    \"script1\": \"echo 'hello vite'\",\n    \"script2\": \"vp run script1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/test-nested-tasks/snap.txt",
    "content": "> vp run script1 # simple task\n$ echo 'hello vite' ⊘ cache disabled\nhello vite\n\n\n> vp run script2 # nested task should work\n$ echo 'hello vite' ⊘ cache disabled\nhello vite\n\n"
  },
  {
    "path": "packages/cli/snap-tests/test-nested-tasks/steps.json",
    "content": "{\n  \"commands\": [\"vp run script1 # simple task\", \"vp run script2 # nested task should work\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-config-task/package.json",
    "content": "{\n  \"name\": \"vite-config-task-test\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-config-task/snap.txt",
    "content": "> # Test that task config is picked up from vite.config.ts\n> vp run build\n$ echo 'build from vite.config.ts' ⊘ cache disabled\nbuild from vite.config.ts\n\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-config-task/steps.json",
    "content": "{\n  \"commands\": [\"# Test that task config is picked up from vite.config.ts\", \"vp run build\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-config-task/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      build: {\n        command: \"echo 'build from vite.config.ts'\",\n        dependsOn: [],\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-task-path-env-include-pm/main.js",
    "content": "import assert from 'node:assert/strict';\nimport { execSync } from 'node:child_process';\n\nconst version = execSync('yarn --version').toString().trim();\nassert.equal(version, '4.12.0');\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-task-path-env-include-pm/package.json",
    "content": "{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"check-yarn-version\": \"node main.js\"\n  },\n  \"packageManager\": \"yarn@4.12.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-task-path-env-include-pm/snap.txt",
    "content": "> vp install --no-frozen-lockfile\n➤ YN0050: The --frozen-lockfile option is deprecated; use --immutable and/or --immutable-cache instead\n\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: │ ESM support for PnP uses the experimental loader API and is therefore experimental\n➤ YN0000: └ Completed\n➤ YN0000: · Done with warnings in <variable>ms <variable>ms\n\n> vp run check-yarn-version\n$ node main.js\n\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-task-path-env-include-pm/steps.json",
    "content": "{\n  \"commands\": [\"vp install --no-frozen-lockfile\", \"vp run check-yarn-version\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vite-task-path-env-include-pm/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/package.json",
    "content": "{\n  \"name\": \"vitest-browser-mode\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/snap.txt",
    "content": "> vp test\n\n RUN  v<semver> <cwd>\n\n ✓ |chromium| src/foo.test.js (1 test) <variable>ms\n\n Test Files  1 passed (1)\n      Tests  1 passed (1)\n   Start at  <date>\n   Duration  <variable>ms (transform <variable>ms, setup <variable>ms, collect <variable>ms, tests <variable>ms, environment <variable>ms, prepare <variable>ms)\n\n\n\n> echo //comment >> src/foo.js\n> vp test\n✗ cache miss: 'src/foo.js' modified, executing\n\n RUN  v<semver> <cwd>\n\n ✓ |chromium| src/foo.test.js (1 test) <variable>ms\n\n Test Files  1 passed (1)\n      Tests  1 passed (1)\n   Start at  <date>\n   Duration  <variable>ms (transform <variable>ms, setup <variable>ms, collect <variable>ms, tests <variable>ms, environment <variable>ms, prepare <variable>ms)\n\n\n\n> echo //comment >> src/bar.js\n> vp test\n✓ cache hit, replaying\n\n RUN  v<semver> <cwd>\n\n ✓ |chromium| src/foo.test.js (1 test) <variable>ms\n\n Test Files  1 passed (1)\n      Tests  1 passed (1)\n   Start at  <date>\n   Duration  <variable>ms (transform <variable>ms, setup <variable>ms, collect <variable>ms, tests <variable>ms, environment <variable>ms, prepare <variable>ms)\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/src/bar.js",
    "content": "export default 'bar';\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/src/foo.js",
    "content": "export default 'foo';\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/src/foo.test.js",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport foo from './foo';\n\ndescribe('foo', () => {\n  it('should equal \"foo\"', () => {\n    expect(foo).toBe('foo');\n  });\n});\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\", \"darwin\", \"linux\"],\n  \"commands\": [\n    \"vp run test\",\n    \"echo //comment >> src/foo.js\",\n    \"vp run test\",\n    \"echo //comment >> src/bar.js\",\n    \"vp run test\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      test: {\n        command: 'vp test',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/vitest-browser-mode/vitest.config.ts",
    "content": "// import { defineProject } from 'vitest/config';\nimport { playwright } from '@vitest/browser-playwright';\n\nexport default {\n  test: {\n    browser: {\n      enabled: true,\n      provider: playwright(),\n      headless: true,\n      instances: [\n        {\n          browser: 'chromium',\n        },\n      ],\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/vp-run-expansion/package.json",
    "content": "{\n  \"name\": \"vp-run-expansion-test\",\n  \"private\": true,\n  \"scripts\": {\n    \"hello\": \"vp run greet\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vp-run-expansion/snap.txt",
    "content": "> vp run hello\n$ node -p '40+2'\n42\n\n\n> vp run hello # should hit cache\n$ node -p '40+2' ◉ cache hit, replaying\n42\n\n---\nvp run: cache hit, <variable>ms saved.\n"
  },
  {
    "path": "packages/cli/snap-tests/vp-run-expansion/steps.json",
    "content": "{\n  \"commands\": [\"vp run hello\", \"vp run hello # should hit cache\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/vp-run-expansion/vite.config.ts",
    "content": "export default {\n  run: {\n    tasks: {\n      greet: {\n        command: \"node -p '40+2'\",\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/package.json",
    "content": "{\n  \"name\": \"workspace-lint-subpackage-test\",\n  \"version\": \"0.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@10.16.1\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/packages/app-a/package.json",
    "content": "{\n  \"name\": \"app-a\",\n  \"version\": \"0.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/packages/app-a/src/index.js",
    "content": "function hello() {\n  console.log('hello from app-a');\n  return 'hello';\n}\n\nexport { hello };\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/packages/app-a/vite.config.ts",
    "content": "export default {\n  lint: {\n    rules: {\n      'no-console': 'off',\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/pnpm-workspace.yaml",
    "content": "packages:\n  - '.'\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/snap.txt",
    "content": "> cd packages/app-a && vp lint # sub-workspace has no-console:off but root has no-console:warn\n\n  ⚠ eslint(no-console): Unexpected console statement.\n   ╭─[src/index.js:2:3]\n 1 │ function hello() {\n 2 │   console.log('hello from app-a');\n   ·   ───────────\n 3 │   return 'hello';\n   ╰────\n  help: Delete this console statement.\n\nFound 1 warning and 0 errors.\nFinished in <variable>ms on 2 files with <variable> rules using <variable> threads.\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"cd packages/app-a && vp lint # sub-workspace has no-console:off but root has no-console:warn\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-lint-subpackage/vite.config.ts",
    "content": "export default {\n  lint: {\n    rules: {\n      'no-console': 'warn',\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/package.json",
    "content": "{\n  \"name\": \"workspace-root-vite-config-test\",\n  \"version\": \"0.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@10.16.1\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/packages/app-a/index.js",
    "content": "console.log('Hello from app-a');\ndebugger;\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/packages/app-a/package.json",
    "content": "{\n  \"name\": \"app-a\",\n  \"version\": \"0.0.0\",\n  \"scripts\": {\n    \"test\": \"echo 'Testing app-a with workspace config'\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/packages/app-b/index.js",
    "content": "function formatCode() {\n  console.log('Formatting with workspace config');\n  return true;\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/packages/app-b/package.json",
    "content": "{\n  \"name\": \"app-b\",\n  \"version\": \"0.0.0\",\n  \"scripts\": {\n    \"test\": \"echo 'Testing app-b with workspace config'\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/pnpm-workspace.yaml",
    "content": "packages:\n  - '.'\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/snap.txt",
    "content": "> # Test that lint picks up workspace root config\n> # vp lint\n> # Test that fmt picks up workspace root config - specify file\n> vp fmt packages/app-b/index.js --check\nChecking formatting...\nAll matched files use the correct format.\nFinished in <variable>ms on 1 files using <variable> threads.\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"# Test that lint picks up workspace root config\",\n    \"# vp lint\",\n    \"# Test that fmt picks up workspace root config - specify file\",\n    \"vp fmt packages/app-b/index.js --check\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/workspace-root-vite-config/vite.config.ts",
    "content": "// Mock vite config for testing workspace root config resolution\n// When this config is read, fmt will use singleQuote: true (file uses single quotes → passes)\n// Without this config, fmt uses default singleQuote: false (double quotes → fails)\nexport default {\n  lint: {\n    rules: {\n      'no-console': 'error',\n      'no-debugger': 'warn',\n    },\n  },\n  fmt: {\n    singleQuote: true,\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests/yarn-install-with-options/package.json",
    "content": "{\n  \"name\": \"yarn-install-with-options\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"tslib\": \"2.8.1\"\n  },\n  \"packageManager\": \"yarn@1.22.22\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/yarn-install-with-options/snap.txt",
    "content": "> vp install --help # print help message\n\n  Usage: yarn install [flags]\n\n  Yarn install is used to install all dependencies for a project.\n\n  Options:\n\n    -v, --version                       output the version number\n    --no-default-rc                     prevent Yarn from automatically detecting yarnrc and npmrc files\n    --use-yarnrc <path>                 specifies a yarnrc file that Yarn should use (.yarnrc only, not .npmrc) (default: )\n    --verbose                           output verbose messages on internal operations\n    --offline                           trigger an error if any required dependencies are not available in local cache\n    --prefer-offline                    use network only if dependencies are not available in local cache\n    --enable-pnp, --pnp                 enable the Plug'n'Play installation\n    --disable-pnp                       disable the Plug'n'Play installation\n    --strict-semver                     \n    --json                              format Yarn log messages as lines of JSON (see jsonlines.org)\n    --ignore-scripts                    don't run lifecycle scripts\n    --har                               save HAR output of network traffic\n    --ignore-platform                   ignore platform checks\n    --ignore-engines                    ignore engines check\n    --ignore-optional                   ignore optional dependencies\n    --force                             install and build packages even if they were built before, overwrite lockfile\n    --skip-integrity-check              run install without checking if node_modules is installed\n    --check-files                       install will verify file tree of packages for consistency\n    --no-bin-links                      don't generate bin links when setting up packages\n    --flat                              only allow one version of a package\n    --prod, --production [prod]         \n    --no-lockfile                       don't read or generate a lockfile\n    --pure-lockfile                     don't generate a lockfile\n    --frozen-lockfile                   don't generate a lockfile and fail if an update is needed\n    --update-checksums                  update package checksums from current repository\n    --link-duplicates                   create hardlinks to the repeated modules in node_modules\n    --link-folder <path>                specify a custom folder to store global links\n    --global-folder <path>              specify a custom folder to store global packages\n    --modules-folder <path>             rather than installing modules into the node_modules folder relative to the cwd, output them here\n    --preferred-cache-folder <path>     specify a custom folder to store the yarn cache if possible\n    --cache-folder <path>               specify a custom folder that must be used to store the yarn cache\n    --mutex <type>[:specifier]          use a mutex to ensure only one yarn instance is executing\n    --emoji [bool]                      enable emoji in output (default: true)\n    -s, --silent                        skip Yarn console logs, other types of logs (script output) will be printed\n    --cwd <cwd>                         working directory to use (default: <cwd>)\n    --proxy <host>                      \n    --https-proxy <host>                \n    --registry <url>                    override configuration registry\n    --no-progress                       disable progress bar\n    --network-concurrency <number>      maximum number of concurrent network requests\n    --network-timeout <milliseconds>    TCP timeout for network requests\n    --non-interactive                   do not show interactive prompts\n    --scripts-prepend-node-path [bool]  prepend the node executable dir to the PATH in scripts\n    --no-node-version-check             do not warn when using a potentially unsupported Node version\n    --focus                             Focus on a single workspace by installing remote copies of its sibling workspaces.\n    --otp <otpcode>                     one-time password for two factor authentication\n    -A, --audit                         Run vulnerability audit on installed packages\n    -g, --global                        DEPRECATED\n    -S, --save                          DEPRECATED - save package to your `dependencies`\n    -D, --save-dev                      DEPRECATED - save package to your `devDependencies`\n    -P, --save-peer                     DEPRECATED - save package to your `peerDependencies`\n    -O, --save-optional                 DEPRECATED - save package to your `optionalDependencies`\n    -E, --save-exact                    DEPRECATED\n    -T, --save-tilde                    DEPRECATED\n    -h, --help                          output usage information\n  Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command.\n\n\n> vp run install\n$ vp install --prod\nyarn install v<semver>\ninfo No lockfile found.\n[1/4] Resolving packages...\n[2/4] Fetching packages...\n[3/4] Linking dependencies...\n[4/4] Building fresh packages...\nsuccess Saved lockfile.\nDone in <variable>ms.\n\n---\nvp run: yarn-install-with-options#install not cached because it modified its input. (Run `vp run --last-details` for full details)\n\n> ls node_modules\ntslib\n\n> vp run install # install again hit cache\n$ vp install --prod\nyarn install v<semver>\n[1/4] Resolving packages...\nsuccess Already up-to-date.\nDone in <variable>ms.\n\n"
  },
  {
    "path": "packages/cli/snap-tests/yarn-install-with-options/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"linux\", \"win32\"],\n  \"env\": {\n    \"NODE_OPTIONS\": \"--no-deprecation\"\n  },\n  \"commands\": [\n    \"vp install --help # print help message\",\n    \"vp run install\",\n    \"ls node_modules\",\n    \"vp run install # install again hit cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests/yarn-install-with-options/vite.config.ts",
    "content": "export default {\n  run: {\n    cache: true,\n    tasks: {\n      install: {\n        command: 'vp install --prod',\n      },\n    },\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/cli-helper-message/snap.txt",
    "content": "> vp -h # show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp [COMMAND]\n\nStart:\n  create      Create a new project from a template\n  migrate     Migrate an existing project to Vite+<repeat>\n  config      Configure hooks and agent integration\n  staged      Run linters on staged files\n  install, i  Install all dependencies, or add packages if package names are provided\n  env         Manage Node.js versions\n\nDevelop:\n  dev    Run the development server\n  check  Run format, lint, and type checks\n  lint   Lint code\n  fmt    Format code\n  test   Run tests\n\nExecute:\n  run    Run tasks\n  exec   Execute a command from local node_modules/.bin\n  dlx    Execute a package binary without installing it as a dependency\n  cache  Manage the task cache\n\nBuild:\n  build    Build for production\n  pack     Build library\n  preview  Preview production build\n\nManage Dependencies:\n  add                        Add packages to dependencies\n  remove, rm, un, uninstall  Remove packages from dependencies\n  update, up                 Update packages to their latest versions\n  dedupe                     Deduplicate dependencies by removing older versions\n  outdated                   Check for outdated packages\n  list, ls                   List installed packages\n  why, explain               Show why a package is installed\n  info, view, show           View package information from the registry\n  link, ln                   Link packages for local development\n  unlink                     Unlink packages\n  pm                         Forward a command to the package manager\n\nMaintain:\n  upgrade  Update vp itself to the latest version\n  implode  Remove vp and all related data\n\nDocumentation: https://viteplus.dev/guide/\n\nOptions:\n  -V, --version  Print version\n  -h, --help     Print help\n\n> vp -V # show version\nVITE+ - The Unified Toolchain for the Web\n\nvp v<semver>\n\nLocal vite-plus:\n  vite-plus  v<semver>\n\nTools:\n  vite             v<semver>\n  rolldown         v<semver>\n  vitest           v<semver>\n  oxfmt            v<semver>\n  oxlint           v<semver>\n  oxlint-tsgolint  v<semver>\n  tsdown           v<semver>\n\nEnvironment:\n  Package manager  Not found\n  Node.js          v<semver>\n\n> vp install -h # show install help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp install [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]\n\nInstall all dependencies, or add packages if package names are provided\n\nArguments:\n  [PACKAGES]...           Packages to add (if provided, acts as `vp add`)\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --prod            Do not install devDependencies\n  -D, --dev             Only install devDependencies (install) / Save to devDependencies (add)\n  --no-optional         Do not install optionalDependencies\n  --frozen-lockfile     Fail if lockfile needs to be updated (CI mode)\n  --no-frozen-lockfile  Allow lockfile updates (opposite of --frozen-lockfile)\n  --lockfile-only       Only update lockfile, don't install\n  --prefer-offline      Use cached packages when available\n  --offline             Only use packages already in cache\n  -f, --force           Force reinstall all dependencies\n  --ignore-scripts      Do not run lifecycle scripts\n  --no-lockfile         Don't read or generate lockfile\n  --fix-lockfile        Fix broken lockfile entries (pnpm and yarn@2+ only)\n  --shamefully-hoist    Create flat `node_modules` (pnpm only)\n  --resolution-only     Re-run resolution for peer dependency analysis (pnpm only)\n  --silent              Suppress output (silent mode)\n  --filter <PATTERN>    Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root  Install in workspace root only\n  -E, --save-exact      Save exact version (only when adding packages)\n  --save-peer           Save to peerDependencies (only when adding packages)\n  -O, --save-optional   Save to optionalDependencies (only when adding packages)\n  --save-catalog        Save the new dependency to the default catalog (only when adding packages)\n  -g, --global          Install globally (only when adding packages)\n  --node <NODE>         Node.js version to use for global installation (only with -g)\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp add -h # show add help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp add [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nAdd packages to dependencies\n\nArguments:\n  <PACKAGES>...           Packages to add\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --save-prod                     Save to `dependencies` (default)\n  -D, --save-dev                      Save to `devDependencies`\n  --save-peer                         Save to `peerDependencies` and `devDependencies`\n  -O, --save-optional                 Save to `optionalDependencies`\n  -E, --save-exact                    Save exact version rather than semver range\n  --save-catalog-name <CATALOG_NAME>  Save the new dependency to the specified catalog name\n  --save-catalog                      Save the new dependency to the default catalog\n  --allow-build <NAMES>               A list of package names allowed to run postinstall\n  --filter <PATTERN>                  Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root                Add to workspace root\n  --workspace                         Only add if package exists in workspace (pnpm-specific)\n  -g, --global                        Install globally\n  --node <NODE>                       Node.js version to use for global installation (only with -g)\n  -h, --help                          Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp remove -h # show remove help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp remove [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nRemove packages from dependencies\n\nArguments:\n  <PACKAGES>...           Packages to remove\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -D, --save-dev        Only remove from `devDependencies` (pnpm-specific)\n  -O, --save-optional   Only remove from `optionalDependencies` (pnpm-specific)\n  -P, --save-prod       Only remove from `dependencies` (pnpm-specific)\n  --filter <PATTERN>    Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root  Remove from workspace root\n  -r, --recursive       Remove recursively from all workspace packages\n  -g, --global          Remove global packages\n  --dry-run             Preview what would be removed without actually removing (only with -g)\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp update -h # show update help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp update [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]\n\nUpdate packages to their latest versions\n\nArguments:\n  [PACKAGES]...           Packages to update (optional - updates all if omitted)\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -L, --latest          Update to latest version (ignore semver range)\n  -g, --global          Update global packages\n  -r, --recursive       Update recursively in all workspace packages\n  --filter <PATTERN>    Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root  Include workspace root\n  -D, --dev             Update only devDependencies\n  -P, --prod            Update only dependencies (production)\n  -i, --interactive     Interactive mode\n  --no-optional         Don't update optionalDependencies\n  --no-save             Update lockfile only, don't modify package.json\n  --workspace           Only update if package exists in workspace (pnpm-specific)\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp link -h # show link help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp link [PACKAGE|DIR] [ARGS]...\n\nLink packages for local development\n\nArguments:\n  [PACKAGE|DIR]  Package name or directory to link\n  [ARGS]...      Arguments to pass to package manager\n\nOptions:\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp unlink -h # show unlink help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp unlink [OPTIONS] [PACKAGE|DIR] [ARGS]...\n\nUnlink packages\n\nArguments:\n  [PACKAGE|DIR]  Package name to unlink\n  [ARGS]...      Arguments to pass to package manager\n\nOptions:\n  -r, --recursive  Unlink in every workspace package\n  -h, --help       Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp dedupe -h # show dedupe help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp dedupe [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nDeduplicate dependencies\n\nArguments:\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --check     Check if deduplication would make changes\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp outdated -h # show outdated help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp outdated [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]\n\nCheck for outdated packages\n\nArguments:\n  [PACKAGES]...           Package name(s) to check\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --long                Show extended information\n  --format <FORMAT>     Output format: table (default), list, or json\n  -r, --recursive       Check recursively across all workspaces\n  --filter <PATTERN>    Filter packages in monorepo\n  -w, --workspace-root  Include workspace root\n  -P, --prod            Only production and optional dependencies\n  -D, --dev             Only dev dependencies\n  --no-optional         Exclude optional dependencies\n  --compatible          Only show compatible versions\n  --sort-by <FIELD>     Sort results by field\n  -g, --global          Check globally installed packages\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp why -h # show why help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp why [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nShow why a package is installed\n\nArguments:\n  <PACKAGES>...           Package(s) to check\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --json                   Output in JSON format\n  --long                   Show extended information\n  --parseable              Show parseable output\n  -r, --recursive          Check recursively across all workspaces\n  --filter <PATTERN>       Filter packages in monorepo\n  -w, --workspace-root     Check in workspace root\n  -P, --prod               Only production dependencies\n  -D, --dev                Only dev dependencies\n  --depth <DEPTH>          Limit tree depth\n  --no-optional            Exclude optional dependencies\n  -g, --global             Check globally installed packages\n  --exclude-peers          Exclude peer dependencies\n  --find-by <FINDER_NAME>  Use a finder function defined in .pnpmfile.cjs\n  -h, --help               Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp info -h # show info help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp info [OPTIONS] <PACKAGE> [FIELD] [-- <PASS_THROUGH_ARGS>...]\n\nView package information from the registry\n\nArguments:\n  <PACKAGE>               Package name with optional version\n  [FIELD]                 Specific field to view\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --json      Output in JSON format\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm -h # show pm help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm <COMMAND>\n\nForward a command to the package manager\n\nCommands:\n  prune      Remove unnecessary packages\n  pack       Create a tarball of the package\n  list       List installed packages [aliases: ls]\n  view       View package information from the registry [aliases: info, show]\n  publish    Publish package to registry\n  owner      Manage package owners [aliases: author]\n  cache      Manage package cache\n  config     Manage package manager configuration [aliases: c]\n  login      Log in to a registry [aliases: adduser]\n  logout     Log out from a registry\n  whoami     Show the current logged-in user\n  token      Manage authentication tokens\n  audit      Run a security audit\n  dist-tag   Manage distribution tags\n  deprecate  Deprecate a package version\n  search     Search for packages in the registry\n  rebuild    Rebuild native modules [aliases: rb]\n  fund       Show funding information for installed packages\n  ping       Ping the registry\n\nOptions:\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp env # show env help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp env [COMMAND]\n\nManage Node.js versions\n\nSetup:\n  setup  Create or update shims in VITE_PLUS_HOME/bin\n  on     Enable managed mode - shims always use vite-plus managed Node.js\n  off    Enable system-first mode - shims prefer system Node.js, fallback to managed\n  print  Print shell snippet to set environment for current session\n\nManage:\n  default    Set or show the global default Node.js version\n  pin        Pin a Node.js version in the current directory (creates .node-version)\n  unpin      Remove the .node-version file from current directory (alias for `pin --unpin`)\n  use        Use a specific Node.js version for this shell session\n  install    Install a Node.js version [aliases: i]\n  uninstall  Uninstall a Node.js version [aliases: uni]\n  exec       Execute a command with a specific Node.js version [aliases: run]\n\nInspect:\n  current      Show current environment information\n  doctor       Run diagnostics and show environment status\n  which        Show path to the tool that would be executed\n  list         List locally installed Node.js versions [aliases: ls]\n  list-remote  List available Node.js versions from the registry [aliases: ls-remote]\n\nExamples:\n  Setup:\n    vp env setup                  # Create shims for node, npm, npx\n    vp env on                     # Use vite-plus managed Node.js\n    vp env print                  # Print shell snippet for this session\n\n  Manage:\n    vp env pin lts                # Pin to latest LTS version\n    vp env install                # Install version from .node-version / package.json\n    vp env use 20                 # Use Node.js 20 for this shell session\n    vp env use --unset            # Remove session override\n\n  Inspect:\n    vp env current                # Show current resolved environment\n    vp env current --json         # JSON output for automation\n    vp env doctor                 # Check environment configuration\n    vp env which node             # Show which node binary will be used\n    vp env list-remote --lts      # List only LTS versions\n\n  Execute:\n    vp env exec --node lts npm i  # Execute 'npm i' with latest LTS\n    vp env exec node -v           # Shim mode (version auto-resolved)\n\nRelated Commands:\n  vp install -g <package>       # Install a package globally\n  vp uninstall -g <package>     # Uninstall a package globally\n  vp update -g [package]        # Update global packages\n  vp list -g [package]          # List global packages\n\nDocumentation: https://viteplus.dev/guide/env\n\n\n> vp upgrade -h # show upgrade help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp upgrade [OPTIONS] [VERSION]\n\nUpdate vp itself to the latest version\n\nArguments:\n  [VERSION]  Target version (e.g., \"0.2.0\"). Defaults to latest\n\nOptions:\n  --tag <TAG>            npm dist-tag to install (default: \"latest\", also: \"alpha\") [default: latest]\n  --check                Check for updates without installing\n  --rollback             Revert to the previously active version\n  --force                Force reinstall even if already on the target version\n  --silent               Suppress output\n  --registry <REGISTRY>  Custom npm registry URL\n  -h, --help             Print help\n\nDocumentation: https://viteplus.dev/guide/upgrade\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/cli-helper-message/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp -h # show help message\",\n    \"vp -V # show version\",\n    \"vp install -h # show install help message\",\n    \"vp add -h # show add help message\",\n    \"vp remove -h # show remove help message\",\n    \"vp update -h # show update help message\",\n    \"vp link -h # show link help message\",\n    \"vp unlink -h # show unlink help message\",\n    \"vp dedupe -h # show dedupe help message\",\n    \"vp outdated -h # show outdated help message\",\n    \"vp why -h # show why help message\",\n    \"vp info -h # show info help message\",\n    \"vp pm -h # show pm help message\",\n    \"vp env # show env help message\",\n    \"vp upgrade -h # show upgrade help message\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10/package.json",
    "content": "{\n  \"name\": \"command-add-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10/snap.txt",
    "content": "> vp add --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp add [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nAdd packages to dependencies\n\nArguments:\n  <PACKAGES>...           Packages to add\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --save-prod                     Save to `dependencies` (default)\n  -D, --save-dev                      Save to `devDependencies`\n  --save-peer                         Save to `peerDependencies` and `devDependencies`\n  -O, --save-optional                 Save to `optionalDependencies`\n  -E, --save-exact                    Save exact version rather than semver range\n  --save-catalog-name <CATALOG_NAME>  Save the new dependency to the specified catalog name\n  --save-catalog                      Save the new dependency to the default catalog\n  --allow-build <NAMES>               A list of package names allowed to run postinstall\n  --filter <PATTERN>                  Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root                Add to workspace root\n  --workspace                         Only add if package exists in workspace (pnpm-specific)\n  -g, --global                        Install globally\n  --node <NODE>                       Node.js version to use for global installation (only with -g)\n  -h, --help                          Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp add testnpm2 -D -- --no-audit && cat package.json # should add package as dev dependencies\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install -- --no-audit && cat package.json # should add packages to dependencies\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --save-peer -- --no-audit && cat package.json # should install package alias for add\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -O -- --no-audit && cat package.json # should add package as optional dependencies\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support pass through arguments\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add --help # should show help\",\n    \"vp add testnpm2 -D -- --no-audit && cat package.json # should add package as dev dependencies\",\n    \"vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install -- --no-audit && cat package.json # should add packages to dependencies\",\n    \"vp install test-vite-plus-package@1.0.0 --save-peer -- --no-audit && cat package.json # should install package alias for add\",\n    \"vp add test-vite-plus-package-optional -O -- --no-audit && cat package.json # should add package as optional dependencies\",\n    \"vp add test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add package to workspace root\n\nadded 3 packages in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter \"*\" -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add -E testnpm2 test-vite-plus-install@1.0.0 --filter \"*\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages include workspace root\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --filter \"*\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should install packages alias for add command\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"^1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"^1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add testnpm2 -D -w -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add package to workspace root\",\n    \"vp add @vite-plus-test/utils --workspace -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\",\n    \"vp add @vite-plus-test/utils --workspace --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter \\\"*\\\" -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\",\n    \"vp add -E testnpm2 test-vite-plus-install@1.0.0 --filter \\\"*\\\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages include workspace root\",\n    \"vp install test-vite-plus-package@1.0.0 --filter \\\"*\\\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should install packages alias for add command\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11/package.json",
    "content": "{\n  \"name\": \"command-add-npm11\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@11.6.2\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11/snap.txt",
    "content": "> vp add --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp add [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nAdd packages to dependencies\n\nArguments:\n  <PACKAGES>...           Packages to add\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --save-prod                     Save to `dependencies` (default)\n  -D, --save-dev                      Save to `devDependencies`\n  --save-peer                         Save to `peerDependencies` and `devDependencies`\n  -O, --save-optional                 Save to `optionalDependencies`\n  -E, --save-exact                    Save exact version rather than semver range\n  --save-catalog-name <CATALOG_NAME>  Save the new dependency to the specified catalog name\n  --save-catalog                      Save the new dependency to the default catalog\n  --allow-build <NAMES>               A list of package names allowed to run postinstall\n  --filter <PATTERN>                  Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root                Add to workspace root\n  --workspace                         Only add if package exists in workspace (pnpm-specific)\n  -g, --global                        Install globally\n  --node <NODE>                       Node.js version to use for global installation (only with -g)\n  -h, --help                          Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp add testnpm2 -D -- --no-audit && cat package.json # should add package as dev dependencies\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm11\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install -- --no-audit && cat package.json # should add packages to dependencies\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm11\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --save-peer -- --no-audit && cat package.json # should install package alias for add\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm11\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -O -- --no-audit && cat package.json # should add package as optional dependencies\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm11\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support pass through arguments\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm11\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add --help # should show help\",\n    \"vp add testnpm2 -D -- --no-audit && cat package.json # should add package as dev dependencies\",\n    \"vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install -- --no-audit && cat package.json # should add packages to dependencies\",\n    \"vp install test-vite-plus-package@1.0.0 --save-peer -- --no-audit && cat package.json # should install package alias for add\",\n    \"vp add test-vite-plus-package-optional -O -- --no-audit && cat package.json # should add package as optional dependencies\",\n    \"vp add test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@11.6.2\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add package to workspace root\n\nadded 3 packages in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter \"*\" -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add -E testnpm2 test-vite-plus-install@1.0.0 --filter \"*\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages include workspace root\n\nup to date in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --filter \"*\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should install packages alias for add command\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-add-npm11-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"^1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"^1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-npm11-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add testnpm2 -D -w -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add package to workspace root\",\n    \"vp add @vite-plus-test/utils --workspace -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\",\n    \"vp add @vite-plus-test/utils --workspace --filter app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter \\\"*\\\" -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\",\n    \"vp add -E testnpm2 test-vite-plus-install@1.0.0 --filter \\\"*\\\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages include workspace root\",\n    \"vp install test-vite-plus-package@1.0.0 --filter \\\"*\\\" --workspace-root -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should install packages alias for add command\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-add-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10/snap.txt",
    "content": "> vp add --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp add [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nAdd packages to dependencies\n\nArguments:\n  <PACKAGES>...           Packages to add\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --save-prod                     Save to `dependencies` (default)\n  -D, --save-dev                      Save to `devDependencies`\n  --save-peer                         Save to `peerDependencies` and `devDependencies`\n  -O, --save-optional                 Save to `optionalDependencies`\n  -E, --save-exact                    Save exact version rather than semver range\n  --save-catalog-name <CATALOG_NAME>  Save the new dependency to the specified catalog name\n  --save-catalog                      Save the new dependency to the default catalog\n  --allow-build <NAMES>               A list of package names allowed to run postinstall\n  --filter <PATTERN>                  Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root                Add to workspace root\n  --workspace                         Only add if package exists in workspace (pnpm-specific)\n  -g, --global                        Install globally\n  --node <NODE>                       Node.js version to use for global installation (only with -g)\n  -h, --help                          Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n[2]> vp add # should error because no packages specified\nerror: the following required arguments were not provided:\n  <PACKAGES>...\n\nUsage: vp add <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nFor more information, try '--help'.\n\n> vp add testnpm2 -D -- --loglevel=verbose --verbose && cat package.json # should add package as dev dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install && cat package.json # should add packages to dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ test-vite-plus-install <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --save-peer && cat package.json # should install package alias for add\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\npeerDependencies:\n+ test-vite-plus-package <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver> already in devDependencies, was not moved to dependencies.\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -O && cat package.json # should add package as optional dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support pass through arguments\n{\n  \"name\": \"command-add-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add --help # should show help\",\n    \"vp add # should error because no packages specified\",\n    \"vp add testnpm2 -D -- --loglevel=verbose --verbose && cat package.json # should add package as dev dependencies\",\n    \"vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install && cat package.json # should add packages to dependencies\",\n    \"vp install test-vite-plus-package@1.0.0 --save-peer && cat package.json # should install package alias for add\",\n    \"vp add test-vite-plus-package-optional -O && cat package.json # should add package as optional dependencies\",\n    \"vp add test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w && cat package.json # should add package to workspace root\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ testnpm2 ^1.0.1\n\nPackages: +<variable>\n+<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add @vite-plus-test/utils --workspace && cat package.json # should add @vite-plus-test/utils to workspace root\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ @vite-plus-test/utils workspace:*\n\nAlready up to date\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\"\n  }\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n.                                        |   +1 +<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add -E testnpm2 test-vite-plus-install --filter \"*\" && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --filter \"*\" --workspace-root --save-catalog && cat package.json packages/app/package.json packages/utils/package.json pnpm-workspace.yaml # should install packages alias for add command\nVITE+ - The Unified Toolchain for the Web\n\n.                                        |   +1 +<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-package\": \"catalog:\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"catalog:\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"catalog:\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\npackages:\n  - packages/*\n\ncatalog:\n  test-vite-plus-package: <semver>\n\n> vp add --filter app test-vite-plus-package-optional --save-catalog-name v1 && cat packages/app/package.json pnpm-workspace.yaml # should add with save-catalog-name\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n.                                        |   +1 +<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"catalog:\",\n    \"test-vite-plus-package-optional\": \"catalog:v1\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\npackages:\n  - packages/*\n\ncatalog:\n  test-vite-plus-package: <semver>\n\ncatalogs:\n  v1:\n    test-vite-plus-package-optional: ^1.0.0\n\n> vp add --filter=./packages/utils test-vite-plus-package-optional -O --save-catalog-name v2 && cat packages/utils/package.json pnpm-workspace.yaml # should add other with save-catalog-name\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"catalog:\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"catalog:v2\"\n  }\n}\npackages:\n  - packages/*\n\ncatalog:\n  test-vite-plus-package: <semver>\n\ncatalogs:\n  v1:\n    test-vite-plus-package-optional: ^1.0.0\n  v2:\n    test-vite-plus-package-optional: ^1.0.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add testnpm2 -D -w && cat package.json # should add package to workspace root\",\n    \"vp add @vite-plus-test/utils --workspace && cat package.json # should add @vite-plus-test/utils to workspace root\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\",\n    \"vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\",\n    \"vp add -E testnpm2 test-vite-plus-install --filter \\\"*\\\" && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\",\n    \"vp install test-vite-plus-package@1.0.0 --filter \\\"*\\\" --workspace-root --save-catalog && cat package.json packages/app/package.json packages/utils/package.json pnpm-workspace.yaml # should install packages alias for add command\",\n    \"vp add --filter app test-vite-plus-package-optional --save-catalog-name v1 && cat packages/app/package.json pnpm-workspace.yaml # should add with save-catalog-name\",\n    \"vp add --filter=./packages/utils test-vite-plus-package-optional -O --save-catalog-name v2 && cat packages/utils/package.json pnpm-workspace.yaml # should add other with save-catalog-name\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9/package.json",
    "content": "{\n  \"name\": \"command-add-pnpm9\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@9.15.9\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9/snap.txt",
    "content": "> vp add --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp add [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nAdd packages to dependencies\n\nArguments:\n  <PACKAGES>...           Packages to add\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --save-prod                     Save to `dependencies` (default)\n  -D, --save-dev                      Save to `devDependencies`\n  --save-peer                         Save to `peerDependencies` and `devDependencies`\n  -O, --save-optional                 Save to `optionalDependencies`\n  -E, --save-exact                    Save exact version rather than semver range\n  --save-catalog-name <CATALOG_NAME>  Save the new dependency to the specified catalog name\n  --save-catalog                      Save the new dependency to the default catalog\n  --allow-build <NAMES>               A list of package names allowed to run postinstall\n  --filter <PATTERN>                  Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root                Add to workspace root\n  --workspace                         Only add if package exists in workspace (pnpm-specific)\n  -g, --global                        Install globally\n  --node <NODE>                       Node.js version to use for global installation (only with -g)\n  -h, --help                          Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp add testnpm2 -D && cat package.json # should add package as dev dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add testnpm2 test-vite-plus-install && cat package.json # should add packages to dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ test-vite-plus-install <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n[1]> vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install # should error because allow-build is not supported at pnpm@9\n ERROR  Unknown option: 'allow-build'\nFor help, run: pnpm help add\n\n> vp install test-vite-plus-package@1.0.0 --save-peer && cat package.json # should install package alias for add\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\npeerDependencies:\n+ test-vite-plus-package <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver> already in devDependencies, was not moved to dependencies.\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -O && cat package.json # should add package as optional dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support pass through arguments\n{\n  \"name\": \"command-add-pnpm9\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"NODE_OPTIONS\": \"--no-deprecation\"\n  },\n  \"commands\": [\n    \"vp add --help # should show help\",\n    \"vp add testnpm2 -D && cat package.json # should add package as dev dependencies\",\n    \"vp add testnpm2 test-vite-plus-install && cat package.json # should add packages to dependencies\",\n    \"vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install # should error because allow-build is not supported at pnpm@9\",\n    \"vp install test-vite-plus-package@1.0.0 --save-peer && cat package.json # should install package alias for add\",\n    \"vp add test-vite-plus-package-optional -O && cat package.json # should add package as optional dependencies\",\n    \"vp add test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-add-pnpm9-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@9.15.9\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w && cat package.json # should add package to workspace root\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ testnpm2 ^1.0.1\n\nPackages: +<variable>\n+<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n[1]> vp add @vite-plus-test/utils --workspace && cat package.json # should add @vite-plus-test/utils to workspace root\n ERR_PNPM_ADDING_TO_ROOT  Running this command will add the dependency to the workspace root, which might not be what you want - if you really meant it, make it explicit by running this command again with the -w flag (or --workspace-root). If you don't want to see this warning anymore, you may set the ignore-workspace-root-check setting to true.\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\n.                                        |  WARN  `node_modules` is present. Lockfile only installation will make it out-of-date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n.                                        |   +1 +<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\n.                                        |  WARN  `node_modules` is present. Lockfile only installation will make it out-of-date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add -E testnpm2 test-vite-plus-install --filter \"*\" && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --filter \"*\" --workspace-root && cat package.json packages/app/package.json packages/utils/package.json pnpm-workspace.yaml # should install packages alias for add command\nVITE+ - The Unified Toolchain for the Web\n\n.                                        |   +1 +<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-add-pnpm9-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\npackages:\n  - packages/*\n\n[1]> vp add --filter app test-vite-plus-package-optional --save-catalog-name v1 # should error because save-catalog-name is not supported at pnpm@9\n ERROR  Unknown option: 'save-catalog-name'\nDid you mean 'save-optional'? Use \"--config.unknown=value\" to force an unknown option.\nFor help, run: pnpm help add\n\n[1]> vp add --filter=./packages/utils test-vite-plus-package-optional -O --save-catalog v2 # should error because save-catalog is not supported at pnpm@9\n ERROR  Unknown option: 'save-catalog-name'\nDid you mean 'save-optional'? Use \"--config.unknown=value\" to force an unknown option.\nFor help, run: pnpm help add\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-pnpm9-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"NODE_OPTIONS\": \"--no-deprecation\"\n  },\n  \"commands\": [\n    \"vp add testnpm2 -D -w && cat package.json # should add package to workspace root\",\n    \"vp add @vite-plus-test/utils --workspace && cat package.json # should add @vite-plus-test/utils to workspace root\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\",\n    \"vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\",\n    \"vp add -E testnpm2 test-vite-plus-install --filter \\\"*\\\" && cat package.json packages/app/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages except workspace root\",\n    \"vp install test-vite-plus-package@1.0.0 --filter \\\"*\\\" --workspace-root && cat package.json packages/app/package.json packages/utils/package.json pnpm-workspace.yaml # should install packages alias for add command\",\n    \"vp add --filter app test-vite-plus-package-optional --save-catalog-name v1 # should error because save-catalog-name is not supported at pnpm@9\",\n    \"vp add --filter=./packages/utils test-vite-plus-package-optional -O --save-catalog v2 # should error because save-catalog is not supported at pnpm@9\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4/package.json",
    "content": "{\n  \"name\": \"command-add-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4/snap.txt",
    "content": "> vp add --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp add [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nAdd packages to dependencies\n\nArguments:\n  <PACKAGES>...           Packages to add\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -P, --save-prod                     Save to `dependencies` (default)\n  -D, --save-dev                      Save to `devDependencies`\n  --save-peer                         Save to `peerDependencies` and `devDependencies`\n  -O, --save-optional                 Save to `optionalDependencies`\n  -E, --save-exact                    Save exact version rather than semver range\n  --save-catalog-name <CATALOG_NAME>  Save the new dependency to the specified catalog name\n  --save-catalog                      Save the new dependency to the default catalog\n  --allow-build <NAMES>               A list of package names allowed to run postinstall\n  --filter <PATTERN>                  Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root                Add to workspace root\n  --workspace                         Only add if package exists in workspace (pnpm-specific)\n  -g, --global                        Install globally\n  --node <NODE>                       Node.js version to use for global installation (only with -g)\n  -h, --help                          Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp add testnpm2 -D && cat package.json # should add package as dev dependencies\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install && cat package.json # should add packages to dependencies\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-install@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp install test-vite-plus-package@1.0.0 --save-peer && cat package.json # should install package alias for add\nVITE+ - The Unified Toolchain for the Web\n\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -O && cat package.json # should add package as optional dependencies\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-package-optional -- --tilde && cat package.json # support pass through arguments\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"~1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add --help # should show help\",\n    \"vp add testnpm2 -D && cat package.json # should add package as dev dependencies\",\n    \"vp add testnpm2 test-vite-plus-install --allow-build=test-vite-plus-install && cat package.json # should add packages to dependencies\",\n    \"vp install test-vite-plus-package@1.0.0 --save-peer && cat package.json # should install package alias for add\",\n    \"vp add test-vite-plus-package-optional -O && cat package.json # should add package as optional dependencies\",\n    \"vp add test-vite-plus-package-optional -- --tilde && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4-with-workspace/packages/admin/package.json",
    "content": "{\n  \"name\": \"admin\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w && cat package.json packages/app/package.json packages/utils/package.json # should add package to workspace root\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace -w && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-install@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n\n> vp add testnpm2 test-vite-plus-install@1.0.0 --filter \"*\" --filter @vite-plus-test/utils && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages and workspace root\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"admin\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp install -O test-vite-plus-package-optional --filter \"*\" && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should install packages alias for add command\nVITE+ - The Unified Toolchain for the Web\n\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"command-add-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"admin\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-add-yarn4-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add testnpm2 -D -w && cat package.json packages/app/package.json packages/utils/package.json # should add package to workspace root\",\n    \"vp add @vite-plus-test/utils --workspace -w && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to workspace root\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add packages to packages/app\",\n    \"vp add @vite-plus-test/utils --workspace --filter app && cat package.json packages/app/package.json packages/utils/package.json # should add @vite-plus-test/utils to packages/app\",\n    \"vp add testnpm2 test-vite-plus-install@1.0.0 --filter \\\"*\\\" --filter @vite-plus-test/utils && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should add testnpm2 test-vite-plus-install to all packages and workspace root\",\n    \"vp install -O test-vite-plus-package-optional --filter \\\"*\\\" && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should install packages alias for add command\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-npm10/package.json",
    "content": "{\n  \"name\": \"command-cache-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-npm10/snap.txt",
    "content": "> vp pm cache dir # should show cache directory (uses npm config get cache)\n<homedir>/.npm\n\n> vp pm cache path # should show cache path (alias for dir, uses npm config get cache)\n<homedir>/.npm\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pm cache dir # should show cache directory (uses npm config get cache)\",\n    \"vp pm cache path # should show cache path (alias for dir, uses npm config get cache)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-cache-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-pnpm10/snap.txt",
    "content": "> vp pm cache --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm cache <SUBCOMMAND> [-- <PASS_THROUGH_ARGS>...]\n\nManage package cache\n\nArguments:\n  <SUBCOMMAND>            Subcommand: dir, path, clean\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm cache dir > /dev/null # should show cache directory (uses pnpm store path)\n> vp pm cache path > /dev/null # should show cache path (alias for dir, uses pnpm store path)"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm cache --help # should show help\",\n    \"vp pm cache dir > /dev/null # should show cache directory (uses pnpm store path)\",\n    \"vp pm cache path > /dev/null # should show cache path (alias for dir, uses pnpm store path)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-yarn4/package.json",
    "content": "{\n  \"name\": \"command-cache-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-yarn4/snap.txt",
    "content": "> vp pm cache dir # should show cache directory (uses yarn config get cacheFolder)\n<homedir>/.yarn/berry/cache\n\n> vp pm cache path # should show cache path (alias for dir, uses yarn config get cacheFolder)\n<homedir>/.yarn/berry/cache\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-cache-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\", \"darwin\"],\n  \"commands\": [\n    \"vp pm cache dir # should show cache directory (uses yarn config get cacheFolder)\",\n    \"vp pm cache path # should show cache path (alias for dir, uses yarn config get cacheFolder)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-check-help/snap.txt",
    "content": "> vp check -h\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp check [OPTIONS] [PATHS]...\n\nRun format, lint, and type checks.\n\nOptions:\n  --fix       Auto-fix format and lint issues\n  --no-fmt    Skip format check\n  --no-lint   Skip lint check\n  -h, --help  Print help\n\nExamples:\n  vp check\n  vp check --fix\n  vp check --no-lint src/index.ts\n\nDocumentation: https://viteplus.dev/guide/check\n\n\n> vp check --help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp check [OPTIONS] [PATHS]...\n\nRun format, lint, and type checks.\n\nOptions:\n  --fix       Auto-fix format and lint issues\n  --no-fmt    Skip format check\n  --no-lint   Skip lint check\n  -h, --help  Print help\n\nExamples:\n  vp check\n  vp check --fix\n  vp check --no-lint src/index.ts\n\nDocumentation: https://viteplus.dev/guide/check\n\n\n> vp help check\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp check [OPTIONS] [PATHS]...\n\nRun format, lint, and type checks.\n\nOptions:\n  --fix       Auto-fix format and lint issues\n  --no-fmt    Skip format check\n  --no-lint   Skip lint check\n  -h, --help  Print help\n\nExamples:\n  vp check\n  vp check --fix\n  vp check --no-lint src/index.ts\n\nDocumentation: https://viteplus.dev/guide/check\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-check-help/steps.json",
    "content": "{\n  \"commands\": [\"vp check -h\", \"vp check --help\", \"vp help check\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-custom-dir-hook-path/package.json",
    "content": "{\n  \"name\": \"command-config-custom-dir-hook-path\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-custom-dir-hook-path/snap.txt",
    "content": "> git init\n> vp config --hooks-only --hooks-dir .config/husky\n> mkdir -p node_modules/.bin && printf '#!/usr/bin/env sh\\necho hook-path-ok' > node_modules/.bin/test-hook-cmd && chmod +x node_modules/.bin/test-hook-cmd\n> mkdir -p .config/husky && printf 'test-hook-cmd\\n' > .config/husky/pre-commit\n> echo test > file.txt && git add file.txt\n> git commit -m 'test' 2>&1 | grep -o 'hook-path-ok' # hook should find test-hook-cmd via PATH\nhook-path-ok\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-custom-dir-hook-path/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    { \"command\": \"vp config --hooks-only --hooks-dir .config/husky\", \"ignoreOutput\": true },\n    {\n      \"command\": \"mkdir -p node_modules/.bin && printf '#!/usr/bin/env sh\\\\necho hook-path-ok' > node_modules/.bin/test-hook-cmd && chmod +x node_modules/.bin/test-hook-cmd\",\n      \"ignoreOutput\": true\n    },\n    {\n      \"command\": \"mkdir -p .config/husky && printf 'test-hook-cmd\\\\n' > .config/husky/pre-commit\",\n      \"ignoreOutput\": true\n    },\n    { \"command\": \"echo test > file.txt && git add file.txt\", \"ignoreOutput\": true },\n    \"git commit -m 'test' 2>&1 | grep -o 'hook-path-ok' # hook should find test-hook-cmd via PATH\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-help/package.json",
    "content": "{\n  \"name\": \"command-config-help\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-help/snap.txt",
    "content": "> vp config -h\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp config [OPTIONS]\n\nConfigure Vite+ for the current project (hooks + agent integration).\n\nOptions:\n  --hooks-dir <path>  Custom hooks directory (default: .vite-hooks)\n  -h, --help          Show this help message\n\nEnvironment:\n  VITE_GIT_HOOKS=0  Skip hook installation\n\nDocumentation: https://viteplus.dev/guide/commit-hooks\n\n\n> vp config --help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp config [OPTIONS]\n\nConfigure Vite+ for the current project (hooks + agent integration).\n\nOptions:\n  --hooks-dir <path>  Custom hooks directory (default: .vite-hooks)\n  -h, --help          Show this help message\n\nEnvironment:\n  VITE_GIT_HOOKS=0  Skip hook installation\n\nDocumentation: https://viteplus.dev/guide/commit-hooks\n\n\n> vp help config\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp config [OPTIONS]\n\nConfigure Vite+ for the current project (hooks + agent integration).\n\nOptions:\n  --hooks-dir <path>  Custom hooks directory (default: .vite-hooks)\n  -h, --help          Show this help message\n\nEnvironment:\n  VITE_GIT_HOOKS=0  Skip hook installation\n\nDocumentation: https://viteplus.dev/guide/commit-hooks\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-help/steps.json",
    "content": "{\n  \"commands\": [\"vp config -h\", \"vp config --help\", \"vp help config\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-no-agent-writes/CLAUDE.md",
    "content": "# My Custom Claude Instructions\n\nDo not modify this file.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-no-agent-writes/package.json",
    "content": "{\n  \"name\": \"command-config-no-agent-writes\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-no-agent-writes/snap.txt",
    "content": "> git init\n> vp config\n> cat CLAUDE.md # should be unchanged\n# My Custom Claude Instructions\n\nDo not modify this file.\n\n> test -f AGENTS.md && echo 'AGENTS.md exists' || echo 'AGENTS.md not created' # should not exist\nAGENTS.md not created\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-no-agent-writes/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp config\",\n    \"cat CLAUDE.md # should be unchanged\",\n    \"test -f AGENTS.md && echo 'AGENTS.md exists' || echo 'AGENTS.md not created' # should not exist\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-npm10/.npmrc",
    "content": "vite-plus-pm-config-test-key = test-value\nfoo = bar\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-npm10/package.json",
    "content": "{\n  \"name\": \"command-config-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-npm10/snap.txt",
    "content": "> # vp pm config set vite-plus-pm-config-test-key test-value --location project # npm set will check valid keys start from npm v9, see https://github.com/npm/cli/issues/5852\n> vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope\ntest-value\n\n> vp pm config delete vite-plus-pm-config-test-key --location project && cat .npmrc # should delete config key from project scope\nfoo=bar\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-npm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"# vp pm config set vite-plus-pm-config-test-key test-value --location project # npm set will check valid keys start from npm v9, see https://github.com/npm/cli/issues/5852\",\n    \"vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope\",\n    \"vp pm config delete vite-plus-pm-config-test-key --location project && cat .npmrc # should delete config key from project scope\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-config-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-pnpm10/snap.txt",
    "content": "> vp pm config --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm config <COMMAND>\n\nManage package manager configuration\n\nCommands:\n  list    List all configuration\n  get     Get configuration value\n  set     Set configuration value\n  delete  Delete configuration key\n\nOptions:\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm config list --location project > /dev/null # should list all project configuration\n> vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope\n> vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope\ntest-value\n\n> vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm config --help # should show help\",\n    \"vp pm config list --location project > /dev/null # should list all project configuration\",\n    \"vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope\",\n    \"vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope\",\n    \"vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-prepare-auto-hooks/package.json",
    "content": "{\n  \"name\": \"command-config-prepare-auto-hooks\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-prepare-auto-hooks/snap.txt",
    "content": "> git init\n> vp config # should install hooks automatically without prompting\n> git config --local core.hooksPath # should be .vite-hooks/_\n.vite-hooks/_\n\n> cat .vite-hooks/pre-commit # should have vp staged\nvp staged\n\n> cat vite.config.ts # should have staged config\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  \n});\n\n> vp config # run again to ensure idempotent\n> cat .vite-hooks/pre-commit # should remain unchanged\nvp staged\n\n> cat vite.config.ts # should remain unchanged\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  \n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-prepare-auto-hooks/steps.json",
    "content": "{\n  \"env\": {\n    \"npm_lifecycle_event\": \"prepare\"\n  },\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp config # should install hooks automatically without prompting\",\n    \"git config --local core.hooksPath # should be .vite-hooks/_\",\n    \"cat .vite-hooks/pre-commit # should have vp staged\",\n    \"cat vite.config.ts # should have staged config\",\n    \"vp config # run again to ensure idempotent\",\n    \"cat .vite-hooks/pre-commit # should remain unchanged\",\n    \"cat vite.config.ts # should remain unchanged\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-replace-husky-hookspath/package.json",
    "content": "{\n  \"name\": \"command-config-replace-husky-hookspath\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-replace-husky-hookspath/snap.txt",
    "content": "> git init\n> git config core.hooksPath .husky/_\n> vp config --hooks-only # should replace .husky/_ with .vite-hooks/_\n> git config --local core.hooksPath # should be .vite-hooks/_\n.vite-hooks/_\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-replace-husky-hookspath/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    { \"command\": \"git config core.hooksPath .husky/_\", \"ignoreOutput\": true },\n    \"vp config --hooks-only # should replace .husky/_ with .vite-hooks/_\",\n    \"git config --local core.hooksPath # should be .vite-hooks/_\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-update-agents/AGENTS.md",
    "content": "# My Project\n\nCustom instructions here.\n\n<!--VITE PLUS START-->\n\nOUTDATED CONTENT THAT SHOULD BE REPLACED\n\n<!--VITE PLUS END-->\n\nMore custom content below.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-update-agents/package.json",
    "content": "{\n  \"name\": \"command-config-update-agents\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-update-agents/snap.txt",
    "content": "> git init\n> vp config # should auto-update agent instructions\n> head -5 AGENTS.md # verify user content preserved\n# My Project\n\nCustom instructions here.\n\n<!--VITE PLUS START-->\n\n> tail -3 AGENTS.md # verify user content preserved\n<!--VITE PLUS END-->\n\nMore custom content below.\n\n> grep -q 'OUTDATED CONTENT' AGENTS.md && echo 'ERROR: outdated content still present' || echo 'outdated content replaced' # verify old content gone\noutdated content replaced\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-update-agents/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp config # should auto-update agent instructions\",\n    \"head -5 AGENTS.md # verify user content preserved\",\n    \"tail -3 AGENTS.md # verify user content preserved\",\n    \"grep -q 'OUTDATED CONTENT' AGENTS.md && echo 'ERROR: outdated content still present' || echo 'outdated content replaced' # verify old content gone\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-yarn1/package.json",
    "content": "{\n  \"name\": \"command-config-yarn1\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@1.22.22\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-yarn1/snap.txt",
    "content": "> vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope (shows warning for yarn@1)\nwarn: yarn@1 does not support --location, ignoring flag\nyarn config v<semver>\nsuccess Set \"vite-plus-pm-config-test-key\" to \"test-value\".\nDone in <variable>ms.\n\n> vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope (shows warning for yarn@1)\nwarn: yarn@1 does not support --location, ignoring flag\ntest-value\n\n> vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope (shows warning for yarn@1)\nwarn: yarn@1 does not support --location, ignoring flag\nyarn config v<semver>\nsuccess Deleted \"vite-plus-pm-config-test-key\".\nDone in <variable>ms.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-yarn1/steps.json",
    "content": "{\n  \"env\": {\n    \"NODE_OPTIONS\": \"--no-deprecation\"\n  },\n  \"commands\": [\n    \"vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope (shows warning for yarn@1)\",\n    \"vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope (shows warning for yarn@1)\",\n    \"vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope (shows warning for yarn@1)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-yarn4/package.json",
    "content": "{\n  \"name\": \"command-config-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-yarn4/snap.txt",
    "content": "[1]> vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope\nUsage Error: Couldn't find a configuration settings named \"vite-plus-pm-config-test-key\"\n\n$ yarn config set [--json] [-H,--home] <name> <value>\n\n[1]> vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope\nUsage Error: Couldn't find a configuration settings named \"vite-plus-pm-config-test-key\"\n\n$ yarn config get [--why] [--json] [--no-redacted] <name>\n\n[1]> vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope (uses yarn config unset)\nUsage Error: Couldn't find a configuration settings named \"vite-plus-pm-config-test-key\"\n\n$ yarn config unset [-H,--home] <name>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-config-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope\",\n    \"vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope\",\n    \"vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope (uses yarn config unset)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-create-help/package.json",
    "content": "{\n  \"name\": \"command-create-help\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-create-help/snap.txt",
    "content": "> vp create -h\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]\n\nUse any builtin, local or remote template with Vite+.\n\nArguments:\n  TEMPLATE  Template name. Run `vp create --list` to see available templates.\n            - Default: vite:monorepo, vite:application, vite:library, vite:generator\n            - Remote: vite, @tanstack/start, create-next-app,\n              create-nuxt, github:user/repo, https://github.com/user/template-repo, etc.\n            - Local: @company/generator-*, ./tools/create-ui-component\n\nOptions:\n  --directory DIR   Target directory for the generated project.\n  --agent NAME      Create an agent instructions file for the specified agent.\n  --editor NAME     Write editor config files for the specified editor.\n  --hooks           Set up pre-commit hooks (default in non-interactive mode)\n  --no-hooks        Skip pre-commit hooks setup\n  --verbose         Show detailed scaffolding output\n  --no-interactive  Run in non-interactive mode\n  --list            List all available templates\n  -h, --help        Show this help message\n\nTemplate Options:\n  Any arguments after -- are passed directly to the template.\n\nExamples:\n  # Interactive mode\n  vp create\n\n  # Use existing templates (shorthand expands to create-* packages)\n  vp create vite\n  vp create @tanstack/start\n  vp create svelte\n  vp create vite -- --template react-ts\n\n  # Full package names also work\n  vp create create-vite\n  vp create create-next-app\n\n  # Create Vite+ monorepo, application, library, or generator scaffolds\n  vp create vite:monorepo\n  vp create vite:application\n  vp create vite:library\n  vp create vite:generator\n\n  # Use templates from GitHub (via degit)\n  vp create github:user/repo\n  vp create https://github.com/user/template-repo\n\nDocumentation: https://viteplus.dev/guide/create\n\n\n> vp create --help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]\n\nUse any builtin, local or remote template with Vite+.\n\nArguments:\n  TEMPLATE  Template name. Run `vp create --list` to see available templates.\n            - Default: vite:monorepo, vite:application, vite:library, vite:generator\n            - Remote: vite, @tanstack/start, create-next-app,\n              create-nuxt, github:user/repo, https://github.com/user/template-repo, etc.\n            - Local: @company/generator-*, ./tools/create-ui-component\n\nOptions:\n  --directory DIR   Target directory for the generated project.\n  --agent NAME      Create an agent instructions file for the specified agent.\n  --editor NAME     Write editor config files for the specified editor.\n  --hooks           Set up pre-commit hooks (default in non-interactive mode)\n  --no-hooks        Skip pre-commit hooks setup\n  --verbose         Show detailed scaffolding output\n  --no-interactive  Run in non-interactive mode\n  --list            List all available templates\n  -h, --help        Show this help message\n\nTemplate Options:\n  Any arguments after -- are passed directly to the template.\n\nExamples:\n  # Interactive mode\n  vp create\n\n  # Use existing templates (shorthand expands to create-* packages)\n  vp create vite\n  vp create @tanstack/start\n  vp create svelte\n  vp create vite -- --template react-ts\n\n  # Full package names also work\n  vp create create-vite\n  vp create create-next-app\n\n  # Create Vite+ monorepo, application, library, or generator scaffolds\n  vp create vite:monorepo\n  vp create vite:application\n  vp create vite:library\n  vp create vite:generator\n\n  # Use templates from GitHub (via degit)\n  vp create github:user/repo\n  vp create https://github.com/user/template-repo\n\nDocumentation: https://viteplus.dev/guide/create\n\n\n> vp help create\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]\n\nUse any builtin, local or remote template with Vite+.\n\nArguments:\n  TEMPLATE  Template name. Run `vp create --list` to see available templates.\n            - Default: vite:monorepo, vite:application, vite:library, vite:generator\n            - Remote: vite, @tanstack/start, create-next-app,\n              create-nuxt, github:user/repo, https://github.com/user/template-repo, etc.\n            - Local: @company/generator-*, ./tools/create-ui-component\n\nOptions:\n  --directory DIR   Target directory for the generated project.\n  --agent NAME      Create an agent instructions file for the specified agent.\n  --editor NAME     Write editor config files for the specified editor.\n  --hooks           Set up pre-commit hooks (default in non-interactive mode)\n  --no-hooks        Skip pre-commit hooks setup\n  --verbose         Show detailed scaffolding output\n  --no-interactive  Run in non-interactive mode\n  --list            List all available templates\n  -h, --help        Show this help message\n\nTemplate Options:\n  Any arguments after -- are passed directly to the template.\n\nExamples:\n  # Interactive mode\n  vp create\n\n  # Use existing templates (shorthand expands to create-* packages)\n  vp create vite\n  vp create @tanstack/start\n  vp create svelte\n  vp create vite -- --template react-ts\n\n  # Full package names also work\n  vp create create-vite\n  vp create create-next-app\n\n  # Create Vite+ monorepo, application, library, or generator scaffolds\n  vp create vite:monorepo\n  vp create vite:application\n  vp create vite:library\n  vp create vite:generator\n\n  # Use templates from GitHub (via degit)\n  vp create github:user/repo\n  vp create https://github.com/user/template-repo\n\nDocumentation: https://viteplus.dev/guide/create\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-create-help/steps.json",
    "content": "{\n  \"commands\": [\"vp create -h\", \"vp create --help\", \"vp help create\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-npm10/package.json",
    "content": "{\n  \"name\": \"command-dedupe-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-npm10/snap.txt",
    "content": "> vp dedupe && cat package.json # should dedupe dependencies\n\nadded 3 packages in <variable>ms\n{\n  \"name\": \"command-dedupe-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp dedupe --check && cat package.json # should check if deduplication would make changes\n\nup to date in <variable>ms\n{\n  \"name\": \"command-dedupe-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp dedupe -- --loglevel=warn && cat package.json # support pass through arguments\n\nup to date in <variable>ms\n{\n  \"name\": \"command-dedupe-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-npm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp dedupe && cat package.json # should dedupe dependencies\",\n    \"vp dedupe --check && cat package.json # should check if deduplication would make changes\",\n    \"vp dedupe -- --loglevel=warn && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-dedupe-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-pnpm10/snap.txt",
    "content": "> vp dedupe --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp dedupe [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nDeduplicate dependencies\n\nArguments:\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --check     Check if deduplication would make changes\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp dedupe && cat package.json # should dedupe dependencies\nAlready up to date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver>\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver>\n\n{\n  \"name\": \"command-dedupe-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp dedupe --check && cat package.json # should check if deduplication would make changes\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\n{\n  \"name\": \"command-dedupe-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp dedupe -- --loglevel=warn && cat package.json # support pass through arguments\n{\n  \"name\": \"command-dedupe-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n[1]> json-edit package.json '_.dependencies = {}' && cat package.json && vp dedupe --check # should check fails because no dependencies\n{\n  \"name\": \"command-dedupe-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {},\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\n ERR_PNPM_DEDUPE_CHECK_ISSUES  Dedupe --check found changes to the lockfile\n\nImporters\n.\n└── - testnpm2 <semver>\n\n\nPackages\n- testnpm2@<semver>\n\nRun pnpm dedupe to apply the changes above.\n\n\n> vp dedupe && cat package.json && vp dedupe --check # should dedupe fix the change by removing the dependencies\nPackages: -1\n-\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n- testnpm2 <semver>\n\n{\n  \"name\": \"command-dedupe-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {},\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp dedupe --help # should show help\",\n    \"vp dedupe && cat package.json # should dedupe dependencies\",\n    \"vp dedupe --check && cat package.json # should check if deduplication would make changes\",\n    \"vp dedupe -- --loglevel=warn && cat package.json # support pass through arguments\",\n    \"json-edit package.json '_.dependencies = {}' && cat package.json && vp dedupe --check # should check fails because no dependencies\",\n    \"vp dedupe && cat package.json && vp dedupe --check # should dedupe fix the change by removing the dependencies\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-yarn4/package.json",
    "content": "{\n  \"name\": \"command-dedupe-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-yarn4/snap.txt",
    "content": "> vp dedupe && cat package.json # should dedupe dependencies\n➤ YN0000: ┌ Deduplication step\n➤ YN0000: │ No packages can be deduped using the highest strategy\n➤ YN0000: └ Completed\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0, test-vite-plus-package@npm:1.0.0, testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-dedupe-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp dedupe --check && cat package.json # should check if deduplication would make changes\n➤ YN0000: ┌ Deduplication step\n➤ YN0000: │ No packages can be deduped using the highest strategy\n➤ YN0000: └ Completed\n{\n  \"name\": \"command-dedupe-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp dedupe -- --json && cat package.json # support pass through arguments\n{\n  \"name\": \"command-dedupe-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dedupe-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp dedupe && cat package.json # should dedupe dependencies\",\n    \"vp dedupe --check && cat package.json # should check if deduplication would make changes\",\n    \"vp dedupe -- --json && cat package.json # support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-no-package-json/snap.txt",
    "content": "> vp dlx -s cowsay hello # should work without package.json\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-no-package-json/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp dlx -s cowsay hello # should work without package.json\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-npm10/package.json",
    "content": "{\n  \"name\": \"command-dlx-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-npm10/snap.txt",
    "content": "> vp dlx --help # should show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp dlx [OPTIONS] <ARGS>...\n\nExecute a package binary without installing it\n\nArguments:\n  <ARGS>...  Package to execute and arguments\n\nOptions:\n  -p, --package <NAME>  Package(s) to install before running\n  -c, --shell-mode      Execute within a shell environment\n  -s, --silent          Suppress all output except the executed command's output\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/vpx\n\n\n> vp dlx -s cowsay hello # should run cowsay with npm exec\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n\n> vp dlx -s cowsay@1.6.0 hello # should run specific version\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n\n> vp dlx -s -p cowsay -p lolcatjs cowsay hello # should run with multiple packages\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n\n> vp dlx -s -p cowsay -c \"echo hello-from-shell | cowsay\" # should run shell mode command\n __________________\n< hello-from-shell >\n ------------------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp dlx --help # should show help message\",\n    \"vp dlx -s cowsay hello # should run cowsay with npm exec\",\n    \"vp dlx -s cowsay@1.6.0 hello # should run specific version\",\n    \"vp dlx -s -p cowsay -p lolcatjs cowsay hello # should run with multiple packages\",\n    \"vp dlx -s -p cowsay -c \\\"echo hello-from-shell | cowsay\\\" # should run shell mode command\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-dlx-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-pnpm10/snap.txt",
    "content": "> vp dlx --help # should show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp dlx [OPTIONS] <ARGS>...\n\nExecute a package binary without installing it\n\nArguments:\n  <ARGS>...  Package to execute and arguments\n\nOptions:\n  -p, --package <NAME>  Package(s) to install before running\n  -c, --shell-mode      Execute within a shell environment\n  -s, --silent          Suppress all output except the executed command's output\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/vpx\n\n\n> vp dlx -s cowsay hello # should run cowsay with pnpm dlx\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n\n> vp dlx -s cowsay@1.6.0 hello # should run specific version\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp dlx --help # should show help message\",\n    \"vp dlx -s cowsay hello # should run cowsay with pnpm dlx\",\n    \"vp dlx -s cowsay@1.6.0 hello # should run specific version\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-yarn4/package.json",
    "content": "{\n  \"name\": \"command-dlx-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-yarn4/snap.txt",
    "content": "> vp dlx --help # should show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp dlx [OPTIONS] <ARGS>...\n\nExecute a package binary without installing it\n\nArguments:\n  <ARGS>...  Package to execute and arguments\n\nOptions:\n  -p, --package <NAME>  Package(s) to install before running\n  -c, --shell-mode      Execute within a shell environment\n  -s, --silent          Suppress all output except the executed command's output\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/vpx\n\n\n> vp dlx -s cowsay hello # should run cowsay with yarn dlx\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n\n> vp dlx -s cowsay@1.6.0 hello # should run specific version\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-dlx-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp dlx --help # should show help message\",\n    \"vp dlx -s cowsay hello # should run cowsay with yarn dlx\",\n    \"vp dlx -s cowsay@1.6.0 hello # should run specific version\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-exec/package.json",
    "content": "{\n  \"name\": \"command-env-exec\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-exec/snap.txt",
    "content": "> vp env exec --node 20.19 node -v # Run node with specific major version\nv20.19.6\n\n> vp env exec --node 20.19 node -e \"console.log('Hello from Node ' + process.version)\" # Run inline script\nHello from Node v<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-exec/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [],\n  \"commands\": [\n    \"vp env exec --node 20.19 node -v # Run node with specific major version\",\n    \"vp env exec --node 20.19 node -e \\\"console.log('Hello from Node ' + process.version)\\\" # Run inline script\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-exec-shim-mode/package.json",
    "content": "{\n  \"name\": \"command-env-exec-shim-mode\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \"20.18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-exec-shim-mode/snap.txt",
    "content": "> vp env exec node -v # Shim mode: version resolved from package.json engines.node\nv20.18.0\n\n> vp env exec npm -v # Shim mode: npm uses same version\n10.8.2\n\n> vp env exec node -e \"console.log('Hello from shim mode')\" # Shim mode: run inline script\nHello from shim mode\n\n> vp env exec nonexistent-tool --version || echo 'Expected error: non-shim command requires --node' # Error: non-shim tool\nvp env exec: --node is required when running non-shim commands\nUsage: vp env exec --node <version> <command> [args...]\n\nFor shim tools, --node is optional (version resolved automatically):\n  vp env exec node script.js    # Core tool\n  vp env exec npm install       # Core tool\n  vp env exec tsc --version     # Global package\nExpected error: non-shim command requires --node\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-exec-shim-mode/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [],\n  \"commands\": [\n    \"vp env exec node -v # Shim mode: version resolved from package.json engines.node\",\n    \"vp env exec npm -v # Shim mode: npm uses same version\",\n    \"vp env exec node -e \\\"console.log('Hello from shim mode')\\\" # Shim mode: run inline script\",\n    \"vp env exec nonexistent-tool --version || echo 'Expected error: non-shim command requires --node' # Error: non-shim tool\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-conflict/conflict-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('conflict-pkg cli');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-conflict/conflict-pkg/package.json",
    "content": "{\n  \"name\": \"conflict-pkg\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Test package with conflicting binary names\",\n  \"bin\": {\n    \"conflict-cli\": \"./cli.js\",\n    \"node\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-conflict/snap.txt",
    "content": "> vp install -g ./conflict-pkg # Install package with conflicting binary name (uses cwd version)\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./conflict-pkg globally...\nwarn: Package './conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping.\nInstalled ./conflict-pkg v<semver>\nBinaries: conflict-cli, node\n\n> vp remove -g conflict-pkg # Cleanup\nUninstalled conflict-pkg\n\n> vp install -g --node 20 ./conflict-pkg # Install with specific Node.js version\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./conflict-pkg globally...\nwarn: Package './conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping.\nInstalled ./conflict-pkg v<semver>\nBinaries: conflict-cli, node\n\n> vp remove -g conflict-pkg # Cleanup\nUninstalled conflict-pkg\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-conflict/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install -g ./conflict-pkg # Install package with conflicting binary name (uses cwd version)\",\n    \"vp remove -g conflict-pkg # Cleanup\",\n    \"vp install -g --node 20 ./conflict-pkg # Install with specific Node.js version\",\n    \"vp remove -g conflict-pkg # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-fail/snap.txt",
    "content": "[1]> vp install -g voidzero-nonexistent-pkg-xyz-12345 # Install non-existent package\nVITE+ - The Unified Toolchain for the Web\n\nInstalling voidzero-nonexistent-pkg-xyz-12345 globally...\nnpm error code E404\nnpm error 404 Not Found - GET https://registry.<domain>/voidzero-nonexistent-pkg-xyz-12345 - Not found\nnpm error 404\nnpm error 404  The requested resource 'voidzero-nonexistent-pkg-xyz-12345@*' could not be found or you do not have permission to access it.\nnpm error 404\nnpm error 404 Note that you can also install from a\nnpm error 404 tarball, folder, http url, or git url.\nnpm error A complete log of this run can be found in: <homedir>/.npm/_logs/<timestamp>-debug.log\nFailed to install voidzero-nonexistent-pkg-xyz-12345: Configuration error: npm install failed with exit code: Some(1)\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-fail/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp install -g voidzero-nonexistent-pkg-xyz-12345 # Install non-existent package\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg/.node-version",
    "content": "22\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg/package.json",
    "content": "{\n  \"name\": \"command-env-install-no-arg\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg/snap.txt",
    "content": "> vp env install # Install version from .node-version (22.x)\nVITE+ - The Unified Toolchain for the Web\n\nInstalling Node.js v<semver>...\nInstalled Node.js v<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {},\n  \"commands\": [\"vp env install # Install version from .node-version (22.x)\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg-fail/package.json",
    "content": "{\n  \"name\": \"command-env-install-no-arg-fail\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg-fail/snap.txt",
    "content": "[1]> vp env install # No version config - should error\nVITE+ - The Unified Toolchain for the Web\n\nNo Node.js version found in current project.\nSpecify a version: vp env install <VERSION>\nOr pin one:       vp env pin <VERSION>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-no-arg-fail/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {},\n  \"commands\": [\"vp env install # No version config - should error\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-node-version/command-env-install-node-version-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('test-pkg cli');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-node-version/command-env-install-node-version-pkg/package.json",
    "content": "{\n  \"name\": \"command-env-install-node-version-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"command-env-install-node-version-pkg-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-node-version/snap.txt",
    "content": "> vp install -g --node 22 ./command-env-install-node-version-pkg # Install with Node.js 22\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./command-env-install-node-version-pkg globally...\nInstalled ./command-env-install-node-version-pkg v<semver>\nBinaries: command-env-install-node-version-pkg-cli\n\n> cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])\" # Verify Node 22\nNode major: 22\n\n> vp remove -g command-env-install-node-version-pkg # Cleanup\nUninstalled command-env-install-node-version-pkg\n\n> vp install -g --node 20 ./command-env-install-node-version-pkg # Install with Node.js 20\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./command-env-install-node-version-pkg globally...\nInstalled ./command-env-install-node-version-pkg v<semver>\nBinaries: command-env-install-node-version-pkg-cli\n\n> cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])\" # Verify Node 20\nNode major: 20\n\n> vp remove -g command-env-install-node-version-pkg # Cleanup\nUninstalled command-env-install-node-version-pkg\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-node-version/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {},\n  \"commands\": [\n    \"vp install -g --node 22 ./command-env-install-node-version-pkg # Install with Node.js 22\",\n    \"cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e \\\"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])\\\" # Verify Node 22\",\n    \"vp remove -g command-env-install-node-version-pkg # Cleanup\",\n    \"vp install -g --node 20 ./command-env-install-node-version-pkg # Install with Node.js 20\",\n    \"cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e \\\"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])\\\" # Verify Node 20\",\n    \"vp remove -g command-env-install-node-version-pkg # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-version-alias/command-env-install-version-alias-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('test-pkg cli');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-version-alias/command-env-install-version-alias-pkg/package.json",
    "content": "{\n  \"name\": \"command-env-install-version-alias-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"command-env-install-version-alias-pkg-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-version-alias/snap.txt",
    "content": "> vp install -g --node lts ./command-env-install-version-alias-pkg # Install with LTS alias\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./command-env-install-version-alias-pkg globally...\nInstalled ./command-env-install-version-alias-pkg v<semver>\nBinaries: command-env-install-version-alias-pkg-cli\n\n> cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('LTS major >= 20:', v >= 20)\" # Verify LTS version\nLTS major >= 20: true\n\n> vp remove -g command-env-install-version-alias-pkg # Cleanup\nUninstalled command-env-install-version-alias-pkg\n\n> vp install -g --node latest ./command-env-install-version-alias-pkg # Install with latest alias\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./command-env-install-version-alias-pkg globally...\nInstalled ./command-env-install-version-alias-pkg v<semver>\nBinaries: command-env-install-version-alias-pkg-cli\n\n> cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('Latest major >= 20:', v >= 20)\" # Verify latest version\nLatest major >= 20: true\n\n> vp remove -g command-env-install-version-alias-pkg # Cleanup\nUninstalled command-env-install-version-alias-pkg\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-install-version-alias/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {},\n  \"commands\": [\n    \"vp install -g --node lts ./command-env-install-version-alias-pkg # Install with LTS alias\",\n    \"cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e \\\"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('LTS major >= 20:', v >= 20)\\\" # Verify LTS version\",\n    \"vp remove -g command-env-install-version-alias-pkg # Cleanup\",\n    \"vp install -g --node latest ./command-env-install-version-alias-pkg # Install with latest alias\",\n    \"cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e \\\"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('Latest major >= 20:', v >= 20)\\\" # Verify latest version\",\n    \"vp remove -g command-env-install-version-alias-pkg # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-use/package.json",
    "content": "{\n  \"name\": \"command-env-use\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-use/snap.txt",
    "content": "> vp env use --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp env use [OPTIONS] [VERSION]\n\nUse a specific Node.js version for this shell session\n\nArguments:\n  [VERSION]  Version to use (e.g., \"20\", \"20.18.0\", \"lts\", \"latest\") If not provided, reads from .node-version or package.json\n\nOptions:\n  --unset                Remove session override (revert to file-based resolution)\n  --no-install           Skip auto-installation if version not present\n  --silent-if-unchanged  Suppress output if version is already active\n  -h, --help             Print help\n\nDocumentation: https://viteplus.dev/guide/env\n\n\n> vp env use 20.18.0 --no-install # should output export command to stdout\nexport VITE_PLUS_NODE_VERSION=20.18.0\nUsing Node.js v<semver> (resolved from <semver>)\n\n> vp env use --unset # should output unset command to stdout\nunset VITE_PLUS_NODE_VERSION\nReverted to file-based Node.js version resolution\n\n[1]> vp env use d # should show friendly error for invalid version\nerror: Invalid Node.js version: \"d\"\n\nValid examples:\n  vp env use 20          # Latest Node.js 20.x\n  vp env use <semver>     # Exact version\n  vp env use lts         # Latest LTS version\n  vp env use latest      # Latest version\n\n[1]> vp env use abc # should show friendly error for invalid version\nerror: Invalid Node.js version: \"abc\"\n\nValid examples:\n  vp env use 20          # Latest Node.js 20.x\n  vp env use <semver>     # Exact version\n  vp env use lts         # Latest LTS version\n  vp env use latest      # Latest version\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-use/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_PLUS_ENV_USE_EVAL_ENABLE\": \"1\"\n  },\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env use --help # should show help\",\n    \"vp env use 20.18.0 --no-install # should output export command to stdout\",\n    \"vp env use --unset # should output unset command to stdout\",\n    \"vp env use d # should show friendly error for invalid version\",\n    \"vp env use abc # should show friendly error for invalid version\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-which/.node-version",
    "content": "20.18.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-which/package.json",
    "content": "{\n  \"name\": \"command-env-which\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-which/snap.txt",
    "content": "> vp env exec node --version # Ensure Node.js is installed first\nv20.18.0\n\n> vp env which node # Core tool - shows resolved Node.js binary path\nVITE+ - The Unified Toolchain for the Web\n\n<vite-plus-home>/js_runtime/node/<semver>/bin/node\n  Version:    <semver>\n  Source:     <cwd>/.node-version\n\n> vp env which npm # Core tool - shows resolved npm binary path\nVITE+ - The Unified Toolchain for the Web\n\n<vite-plus-home>/js_runtime/node/<semver>/bin/npm\n  Version:    <semver>\n  Source:     <cwd>/.node-version\n\n> vp env which npx # Core tool - shows resolved npx binary path\nVITE+ - The Unified Toolchain for the Web\n\n<vite-plus-home>/js_runtime/node/<semver>/bin/npx\n  Version:    <semver>\n  Source:     <cwd>/.node-version\n\n> vp install -g cowsay@1.6.0 # Install a global package via vp\nVITE+ - The Unified Toolchain for the Web\n\nInstalling cowsay@<semver> globally...\nInstalled cowsay v<semver>\nBinaries: cowsay, cowthink\n\n> vp env which cowsay # Global package - shows binary path with metadata\nVITE+ - The Unified Toolchain for the Web\n\n<vite-plus-home>/packages/cowsay/lib/node_modules/cowsay/./cli.js\n  Package:    cowsay@<semver>\n  Binaries:   cowsay, cowthink\n  Node:       <semver>\n  Installed:  <date>\n\n> vp remove -g cowsay # Cleanup\nUninstalled cowsay\n\n[1]> vp env which unknown-tool # Unknown tool - error message\nVITE+ - The Unified Toolchain for the Web\n\nerror: tool 'unknown-tool' not found\nNot a core tool (node, npm, npx) or installed global package.\nRun 'vp list -g' to see installed packages.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-env-which/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env exec node --version # Ensure Node.js is installed first\",\n    \"vp env which node # Core tool - shows resolved Node.js binary path\",\n    \"vp env which npm # Core tool - shows resolved npm binary path\",\n    \"vp env which npx # Core tool - shows resolved npx binary path\",\n    \"vp install -g cowsay@1.6.0 # Install a global package via vp\",\n    \"vp env which cowsay # Global package - shows binary path with metadata\",\n    \"vp remove -g cowsay # Cleanup\",\n    \"vp env which unknown-tool # Unknown tool - error message\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-exec/package.json",
    "content": "{\n  \"name\": \"command-exec-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-exec/setup-bin.js",
    "content": "const fs = require('fs');\nfs.mkdirSync('node_modules/.bin', { recursive: true });\nfs.writeFileSync(\n  'node_modules/.bin/hello-test',\n  '#!/usr/bin/env node\\nconsole.log(\"hello from test-bin\");\\n',\n  { mode: 0o755 },\n);\nfs.writeFileSync('node_modules/.bin/hello-test.cmd', '@node \"%~dp0\\\\hello-test\" %*\\n');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-exec/snap.txt",
    "content": "> node setup-bin.js\n> vp exec hello-test # exec binary from node_modules/.bin\nVITE+ - The Unified Toolchain for the Web\n\nhello from test-bin\n\n> vp exec echo hello # basic exec\nVITE+ - The Unified Toolchain for the Web\n\nhello\n\n> vp exec -- echo with-separator # explicit -- separator\nVITE+ - The Unified Toolchain for the Web\n\nwith-separator\n\n> vp exec node -e \"console.log('hi')\" # exec with args passthrough\nVITE+ - The Unified Toolchain for the Web\n\nhi\n\n> vp exec -c 'echo hello from shell' # shell mode\nVITE+ - The Unified Toolchain for the Web\n\nhello from shell\n\n> vp exec --help # should show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp exec [OPTIONS] [COMMAND]...\n\nExecute a command from local node_modules/.bin.\n\nArguments:\n  [COMMAND]...  Command and arguments to execute\n\nOptions:\n  -r, --recursive              Select all packages in the workspace\n  -t, --transitive             Select the current package and its transitive dependencies\n  -w, --workspace-root         Select the workspace root package\n  -F, --filter <FILTERS>       Match packages by name, directory, or glob pattern\n  -c, --shell-mode             Execute the command within a shell environment\n  --parallel                   Run concurrently without topological ordering\n  --reverse                    Reverse execution order\n  --resume-from <RESUME_FROM>  Resume from a specific package\n  --report-summary             Save results to vp-exec-summary.json\n  -h, --help                   Print help (see more with '--help')\n\nFilter Patterns:\n  --filter <pattern>        Select by package name (e.g. foo, @scope/*)\n  --filter ./<dir>          Select packages under a directory\n  --filter {<dir>}          Same as ./<dir>, but allows traversal suffixes\n  --filter <pattern>...     Select package and its dependencies\n  --filter ...<pattern>     Select package and its dependents\n  --filter <pattern>^...    Select only the dependencies (exclude the package itself)\n  --filter !<pattern>       Exclude packages matching the pattern\n\nExamples:\n  vp exec node --version                             # Run local node\n  vp exec tsc --noEmit                               # Run local TypeScript compiler\n  vp exec -c 'tsc --noEmit && prettier --check .'    # Shell mode\n  vp exec -r -- tsc --noEmit                         # Run in all workspace packages\n  vp exec --filter 'app...' -- tsc                   # Run in filtered packages\n\nDocumentation: https://viteplus.dev/guide/vpx\n\n\n[1]> vp exec # missing command should error\nVITE+ - The Unified Toolchain for the Web\n\nerror: 'vp exec' requires a command to run\n\nUsage: vp exec [--] <command> [args...]\n\nExamples:\n  vp exec node --version\n  vp exec tsc --noEmit\n\n[1]> vp exec nonexistent-cmd-12345 # command not found error\nVITE+ - The Unified Toolchain for the Web\n\nerror: Command 'nonexistent-cmd-12345' not found in node_modules/.bin\n\nRun `vp install` to install dependencies, or use `vpx` for invoking remote commands.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-exec/steps.json",
    "content": "{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    { \"command\": \"node setup-bin.js\", \"ignoreOutput\": true },\n    \"vp exec hello-test # exec binary from node_modules/.bin\",\n    \"vp exec echo hello # basic exec\",\n    \"vp exec -- echo with-separator # explicit -- separator\",\n    \"vp exec node -e \\\"console.log('hi')\\\" # exec with args passthrough\",\n    \"vp exec -c 'echo hello from shell' # shell mode\",\n    \"vp exec --help # should show help message\",\n    \"vp exec # missing command should error\",\n    \"vp exec nonexistent-cmd-12345 # command not found error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-fmt-help/snap.txt",
    "content": "> vp fmt -h\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp fmt [PATH]... [OPTIONS]\n\nFormat code.\nOptions are forwarded to Oxfmt.\n\nOptions:\n  --write               Format and write files in place\n  --check               Check if files are formatted\n  --list-different      List files that would be changed\n  --ignore-path <PATH>  Path to ignore file(s)\n  --threads <INT>       Number of threads to use\n  -h, --help            Print help\n\nExamples:\n  vp fmt\n  vp fmt src --check\n  vp fmt . --write\n\nDocumentation: https://viteplus.dev/guide/fmt\n\n\n> vp fmt --help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp fmt [PATH]... [OPTIONS]\n\nFormat code.\nOptions are forwarded to Oxfmt.\n\nOptions:\n  --write               Format and write files in place\n  --check               Check if files are formatted\n  --list-different      List files that would be changed\n  --ignore-path <PATH>  Path to ignore file(s)\n  --threads <INT>       Number of threads to use\n  -h, --help            Print help\n\nExamples:\n  vp fmt\n  vp fmt src --check\n  vp fmt . --write\n\nDocumentation: https://viteplus.dev/guide/fmt\n\n\n> vp help fmt\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp fmt [PATH]... [OPTIONS]\n\nFormat code.\nOptions are forwarded to Oxfmt.\n\nOptions:\n  --write               Format and write files in place\n  --check               Check if files are formatted\n  --list-different      List files that would be changed\n  --ignore-path <PATH>  Path to ignore file(s)\n  --threads <INT>       Number of threads to use\n  -h, --help            Print help\n\nExamples:\n  vp fmt\n  vp fmt src --check\n  vp fmt . --write\n\nDocumentation: https://viteplus.dev/guide/fmt\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-fmt-help/steps.json",
    "content": "{\n  \"commands\": [\"vp fmt -h\", \"vp fmt --help\", \"vp help fmt\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-auto-create-package-json/snap.txt",
    "content": "> test ! -f package.json && echo 'no package.json' # verify no package.json exists\nno package.json\n\n> vp install --silent && cat package.json # should auto-create package.json and install\n{\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@<semver>\"\n}\n> vp add testnpm2 -D && cat package.json # should add package to auto-created package.json\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-auto-create-package-json/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"test ! -f package.json && echo 'no package.json' # verify no package.json exists\",\n    \"vp install --silent && cat package.json # should auto-create package.json and install\",\n    \"vp add testnpm2 -D && cat package.json # should add package to auto-created package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-bug-31/package.json",
    "content": "{\n  \"name\": \"command-install-bug-31\",\n  \"version\": \"1.0.0\",\n  \"description\": \"bug fix https://github.com/voidzero-dev/vite-task/issues/31\",\n  \"packageManager\": \"pnpm@10.24.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-bug-31/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-bug-31/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-bug-31/snap.txt",
    "content": "> vp install --no-frozen-lockfile --silent # install dependencies work\n> mkdir -p packages/sub-project && echo '{\"name\": \"sub-project\", \"dependencies\": { \"testnpm2\": \"1.0.0\" }}' > packages/sub-project/package.json # create sub project and package.json\n> vp install --no-frozen-lockfile --silent # install again should work and without cache\n> ls packages/sub-project/node_modules/testnpm2/package.json # check testnpm2 is installed\npackages/sub-project/node_modules/testnpm2/package.json\n\n> mkdir -p others/other && echo '{\"name\": \"other\", \"dependencies\": { \"testnpm2\": \"1.0.0\" }}' > others/other/package.json # create non workspace project\n> vp install --no-frozen-lockfile --silent # should install cache hit\n> test -d others/other/node_modules/testnpm2 && echo 'Error: directory exists.' >&2 && exit 1 || true # check testnpm2 is not installed\n> rm -rf packages/sub-project # remove sub project\n> vp install --no-frozen-lockfile --silent | sed -E 's|packages.*|packages*|' # should install again without cache"
  },
  {
    "path": "packages/cli/snap-tests-global/command-install-bug-31/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp install --no-frozen-lockfile --silent # install dependencies work\",\n    \"mkdir -p packages/sub-project && echo '{\\\"name\\\": \\\"sub-project\\\", \\\"dependencies\\\": { \\\"testnpm2\\\": \\\"1.0.0\\\" }}' > packages/sub-project/package.json # create sub project and package.json\",\n    \"vp install --no-frozen-lockfile --silent # install again should work and without cache\",\n    \"ls packages/sub-project/node_modules/testnpm2/package.json # check testnpm2 is installed\",\n    \"mkdir -p others/other && echo '{\\\"name\\\": \\\"other\\\", \\\"dependencies\\\": { \\\"testnpm2\\\": \\\"1.0.0\\\" }}' > others/other/package.json # create non workspace project\",\n    \"vp install --no-frozen-lockfile --silent # should install cache hit\",\n    \"test -d others/other/node_modules/testnpm2 && echo 'Error: directory exists.' >&2 && exit 1 || true # check testnpm2 is not installed\",\n    \"rm -rf packages/sub-project # remove sub project\",\n    \"vp install --no-frozen-lockfile --silent | sed -E 's|packages.*|packages*|' # should install again without cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-npm10/package.json",
    "content": "{\n  \"name\": \"command-link-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-npm10/snap.txt",
    "content": "> mkdir -p ../test-lib-npm && echo '{\"name\": \"test-lib-npm\", \"version\": \"1.0.0\"}' > ../test-lib-npm/package.json # create test library\n> vp link ../test-lib-npm && cat package.json # should link local directory\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-link-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp ln ../test-lib-npm && cat package.json # should work with ln alias\n\nup to date in <variable>ms\n{\n  \"name\": \"command-link-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp unlink test-lib-npm && cat package.json # cleanup temp states\n\nremoved 1 package in <variable>ms\n{\n  \"name\": \"command-link-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p ../test-lib-npm && echo '{\\\"name\\\": \\\"test-lib-npm\\\", \\\"version\\\": \\\"1.0.0\\\"}' > ../test-lib-npm/package.json # create test library\",\n    \"vp link ../test-lib-npm && cat package.json # should link local directory\",\n    \"vp ln ../test-lib-npm && cat package.json # should work with ln alias\",\n    \"vp unlink test-lib-npm && cat package.json # cleanup temp states\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-link-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-pnpm10/snap.txt",
    "content": "> vp link -h # should show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp link [PACKAGE|DIR] [ARGS]...\n\nLink packages for local development\n\nArguments:\n  [PACKAGE|DIR]  Package name or directory to link\n  [ARGS]...      Arguments to pass to package manager\n\nOptions:\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp install # install initial dependencies\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\n\n> mkdir -p ../test-lib-pnpm && echo '{\"name\": \"testnpm2\", \"version\": \"1.0.0\"}' > ../test-lib-pnpm/package.json # create test library\n> vp link ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should link local directory\nPackages: -1\n-\n\ndependencies:\n- testnpm2 <semver>\n+ testnpm2 <semver> <- ../test-lib-pnpm\n\n{\n  \"name\": \"command-link-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\nlockfileVersion: '9.0'\n\nsettings:\n  autoInstallPeers: true\n  excludeLinksFromLockfile: false\n\noverrides:\n  testnpm2: link:../test-lib-pnpm\n\nimporters:\n\n  .:\n    dependencies:\n      testnpm2:\n        specifier: link:../test-lib-pnpm\n        version: link:../test-lib-pnpm\n\n> vp ln ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should work with ln alias\nLockfile is up to date, resolution step is skipped\n\n{\n  \"name\": \"command-link-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\nlockfileVersion: '9.0'\n\nsettings:\n  autoInstallPeers: true\n  excludeLinksFromLockfile: false\n\noverrides:\n  testnpm2: link:../test-lib-pnpm\n\nimporters:\n\n  .:\n    dependencies:\n      testnpm2:\n        specifier: link:../test-lib-pnpm\n        version: link:../test-lib-pnpm\n\n> vp unlink ../test-lib-pnpm && vp unlink testnpm2 && cat package.json pnpm-lock.yaml # should unlink the package\nNothing to unlink\nNothing to unlink\n{\n  \"name\": \"command-link-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\nlockfileVersion: '9.0'\n\nsettings:\n  autoInstallPeers: true\n  excludeLinksFromLockfile: false\n\noverrides:\n  testnpm2: link:../test-lib-pnpm\n\nimporters:\n\n  .:\n    dependencies:\n      testnpm2:\n        specifier: link:../test-lib-pnpm\n        version: link:../test-lib-pnpm\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\", \"linux\"],\n  \"commands\": [\n    \"vp link -h # should show help message\",\n    \"vp install # install initial dependencies\",\n    \"mkdir -p ../test-lib-pnpm && echo '{\\\"name\\\": \\\"testnpm2\\\", \\\"version\\\": \\\"1.0.0\\\"}' > ../test-lib-pnpm/package.json # create test library\",\n    \"vp link ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should link local directory\",\n    \"vp ln ../test-lib-pnpm && cat package.json pnpm-lock.yaml # should work with ln alias\",\n    \"vp unlink ../test-lib-pnpm && vp unlink testnpm2 && cat package.json pnpm-lock.yaml # should unlink the package\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-yarn4/package.json",
    "content": "{\n  \"name\": \"command-link-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-yarn4/snap.txt",
    "content": "> mkdir -p ../test-lib-yarn && echo '{\"name\": \"test-lib-yarn\", \"version\": \"1.0.0\"}' > ../test-lib-yarn/package.json # create test library\n> vp link ../test-lib-yarn && cat package.json # should link local directory\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-link-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"resolutions\": {\n    \"test-lib-yarn\": \"portal:<cwd>/../test-lib-yarn\"\n  }\n}\n\n> vp ln ../test-lib-yarn && cat package.json # should work with ln alias\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-link-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"resolutions\": {\n    \"test-lib-yarn\": \"portal:<cwd>/../test-lib-yarn\"\n  }\n}\n\n> vp unlink test-lib-yarn && cat package.json # cleanup temp states\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-link-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-link-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p ../test-lib-yarn && echo '{\\\"name\\\": \\\"test-lib-yarn\\\", \\\"version\\\": \\\"1.0.0\\\"}' > ../test-lib-yarn/package.json # create test library\",\n    \"vp link ../test-lib-yarn && cat package.json # should link local directory\",\n    \"vp ln ../test-lib-yarn && cat package.json # should work with ln alias\",\n    \"vp unlink test-lib-yarn && cat package.json # cleanup temp states\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-lint-help/snap.txt",
    "content": "> vp lint -h\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp lint [PATH]... [OPTIONS]\n\nLint code.\nOptions are forwarded to Oxlint.\n\nOptions:\n  --tsconfig <PATH>  TypeScript tsconfig path\n  --fix              Fix issues when possible\n  --type-aware       Enable rules requiring type information\n  --import-plugin    Enable import plugin\n  --rules            List registered rules\n  -h, --help         Print help\n\nExamples:\n  vp lint\n  vp lint src --fix\n  vp lint --type-aware --tsconfig ./tsconfig.json\n\nDocumentation: https://viteplus.dev/guide/lint\n\n\n> vp lint --help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp lint [PATH]... [OPTIONS]\n\nLint code.\nOptions are forwarded to Oxlint.\n\nOptions:\n  --tsconfig <PATH>  TypeScript tsconfig path\n  --fix              Fix issues when possible\n  --type-aware       Enable rules requiring type information\n  --import-plugin    Enable import plugin\n  --rules            List registered rules\n  -h, --help         Print help\n\nExamples:\n  vp lint\n  vp lint src --fix\n  vp lint --type-aware --tsconfig ./tsconfig.json\n\nDocumentation: https://viteplus.dev/guide/lint\n\n\n> vp help lint\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp lint [PATH]... [OPTIONS]\n\nLint code.\nOptions are forwarded to Oxlint.\n\nOptions:\n  --tsconfig <PATH>  TypeScript tsconfig path\n  --fix              Fix issues when possible\n  --type-aware       Enable rules requiring type information\n  --import-plugin    Enable import plugin\n  --rules            List registered rules\n  -h, --help         Print help\n\nExamples:\n  vp lint\n  vp lint src --fix\n  vp lint --type-aware --tsconfig ./tsconfig.json\n\nDocumentation: https://viteplus.dev/guide/lint\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-lint-help/steps.json",
    "content": "{\n  \"commands\": [\"vp lint -h\", \"vp lint --help\", \"vp help lint\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-no-package-json/snap.txt",
    "content": "[1]> vp ls # should output nothing without package.json\nNo package.json found.\n\n[1]> vp pm list # should output nothing without package.json\nNo package.json found.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-no-package-json/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp ls # should output nothing without package.json\",\n    \"vp pm list # should output nothing without package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10/package.json",
    "content": "{\n  \"name\": \"command-list-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.8.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 3 packages in <variable>ms\n\n> vp pm list --json # should list installed packages\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list testnpm2 --json # should list specific package\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list --depth 0 --json # should list packages with depth limit\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list --long --json # should list packages with extended info\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\",\n  \"_id\": \"command-list-npm10@<semver>\",\n  \"extraneous\": false,\n  \"path\": \"<cwd>\",\n  \"_dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false,\n      \"name\": \"test-vite-plus-package-optional\",\n      \"integrity\": \"sha512-<hash>\",\n      \"license\": \"MIT\",\n      \"peer\": true,\n      \"_id\": \"test-vite-plus-package-optional@<semver>\",\n      \"extraneous\": false,\n      \"path\": \"<cwd>/node_modules/test-vite-plus-package-optional\",\n      \"_dependencies\": {},\n      \"devDependencies\": {},\n      \"peerDependencies\": {}\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false,\n      \"name\": \"test-vite-plus-package\",\n      \"integrity\": \"sha512-<hash>\",\n      \"dev\": true,\n      \"license\": \"MIT\",\n      \"_id\": \"test-vite-plus-package@<semver>\",\n      \"extraneous\": false,\n      \"path\": \"<cwd>/node_modules/test-vite-plus-package\",\n      \"_dependencies\": {},\n      \"devDependencies\": {},\n      \"peerDependencies\": {}\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false,\n      \"name\": \"testnpm2\",\n      \"integrity\": \"sha512-<hash>\",\n      \"license\": \"ISC\",\n      \"_id\": \"testnpm2@<semver>\",\n      \"extraneous\": false,\n      \"path\": \"<cwd>/node_modules/testnpm2\",\n      \"_dependencies\": {},\n      \"devDependencies\": {},\n      \"peerDependencies\": {}\n    }\n  }\n}\n\n> vp pm list --parseable --json # should list packages in parseable format\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list --prod --json # should list production dependencies only (uses --include prod --include peer)\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list --dev --json # should list development dependencies only (uses --include dev)\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list --no-optional --json # should exclude optional dependencies (uses --omit optional)\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list --exclude-peers --json # should exclude peer dependencies (uses --omit peer)\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n\n> vp pm list -- --loglevel=warn --json # should support pass through arguments\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10\",\n  \"dependencies\": {\n    \"test-vite-plus-package-optional\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"test-vite-plus-package\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n      \"overridden\": false\n    },\n    \"testnpm2\": {\n      \"version\": \"1.0.1\",\n      \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n      \"overridden\": false\n    }\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm list --json # should list installed packages\",\n    \"vp pm list testnpm2 --json # should list specific package\",\n    \"vp pm list --depth 0 --json # should list packages with depth limit\",\n    \"vp pm list --long --json # should list packages with extended info\",\n    \"vp pm list --parseable --json # should list packages in parseable format\",\n    \"vp pm list --prod --json # should list production dependencies only (uses --include prod --include peer)\",\n    \"vp pm list --dev --json # should list development dependencies only (uses --include dev)\",\n    \"vp pm list --no-optional --json # should exclude optional dependencies (uses --omit optional)\",\n    \"vp pm list --exclude-peers --json # should exclude peer dependencies (uses --omit peer)\",\n    \"vp pm list -- --loglevel=warn --json # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-list-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@10.8.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10-with-workspace/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 4 packages in <variable>ms\n\n> vp pm list --json # should list current workspace root dependencies\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10-with-workspace\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../../packages/utils\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"testnpm2\": {\n          \"version\": \"1.0.1\",\n          \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n          \"overridden\": false\n        }\n      }\n    },\n    \"app\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../packages/app\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"test-vite-plus-package-optional\": {\n          \"version\": \"1.0.0\",\n          \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n          \"overridden\": false\n        },\n        \"testnpm2\": {\n          \"version\": \"1.0.1\"\n        }\n      }\n    }\n  }\n}\n\n> vp pm list --recursive --json # should list all packages in workspace (uses --workspaces)\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10-with-workspace\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../../packages/utils\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"testnpm2\": {\n          \"version\": \"1.0.1\",\n          \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n          \"overridden\": false\n        }\n      }\n    },\n    \"app\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../packages/app\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"test-vite-plus-package-optional\": {\n          \"version\": \"1.0.0\",\n          \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n          \"overridden\": false\n        },\n        \"testnpm2\": {\n          \"version\": \"1.0.1\"\n        }\n      }\n    }\n  }\n}\n\n> vp pm list --filter app --json # should list specific workspace package (uses --workspace app)\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10-with-workspace\",\n  \"dependencies\": {\n    \"app\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../packages/app\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"test-vite-plus-package-optional\": {\n          \"version\": \"1.0.0\",\n          \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n          \"overridden\": false\n        },\n        \"testnpm2\": {\n          \"version\": \"1.0.1\",\n          \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n          \"overridden\": false\n        }\n      }\n    }\n  }\n}\n\n> vp pm list --filter app --filter @vite-plus-test/utils --json # should list multiple workspace packages\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10-with-workspace\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../../packages/utils\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"testnpm2\": {\n          \"version\": \"1.0.1\",\n          \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n          \"overridden\": false\n        }\n      }\n    },\n    \"app\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../packages/app\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"test-vite-plus-package-optional\": {\n          \"version\": \"1.0.0\",\n          \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n          \"overridden\": false\n        },\n        \"testnpm2\": {\n          \"version\": \"1.0.1\"\n        }\n      }\n    }\n  }\n}\n\n> vp pm list --recursive --depth 0 --json # should list workspace packages with depth limit\n{\n  \"version\": \"1.0.0\",\n  \"name\": \"command-list-npm10-with-workspace\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../../packages/utils\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"testnpm2\": {\n          \"version\": \"1.0.1\",\n          \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n          \"overridden\": false\n        }\n      }\n    },\n    \"app\": {\n      \"version\": \"1.0.0\",\n      \"resolved\": \"file:../packages/app\",\n      \"overridden\": false,\n      \"dependencies\": {\n        \"test-vite-plus-package-optional\": {\n          \"version\": \"1.0.0\",\n          \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n          \"overridden\": false\n        },\n        \"testnpm2\": {\n          \"version\": \"1.0.1\"\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-npm10-with-workspace/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm list --json # should list current workspace root dependencies\",\n    \"vp pm list --recursive --json # should list all packages in workspace (uses --workspaces)\",\n    \"vp pm list --filter app --json # should list specific workspace package (uses --workspace app)\",\n    \"vp pm list --filter app --filter @vite-plus-test/utils --json # should list multiple workspace packages\",\n    \"vp pm list --recursive --depth 0 --json # should list workspace packages with depth limit\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-list-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ test-vite-plus-package-optional <semver>\n+ testnpm2 <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver>\n\nDone in <variable>ms using pnpm v<semver>\n\n> vp pm list --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm list [OPTIONS] [PATTERN] [-- <PASS_THROUGH_ARGS>...]\n\nList installed packages\n\nArguments:\n  [PATTERN]               Package pattern to filter\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  --depth <DEPTH>          Maximum depth of dependency tree\n  --json                   Output in JSON format\n  --long                   Show extended information\n  --parseable              Parseable output format\n  -P, --prod               Only production dependencies\n  -D, --dev                Only dev dependencies\n  --no-optional            Exclude optional dependencies\n  --exclude-peers          Exclude peer dependencies\n  --only-projects          Show only project packages\n  --find-by <FINDER_NAME>  Use a finder function\n  -r, --recursive          List across all workspaces\n  --filter <PATTERN>       Filter packages in monorepo\n  -g, --global             List global packages\n  -h, --help               Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm list # should list installed packages\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp pm list testnpm2 # should list specific package\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\n> vp pm list --depth 0 # should list packages with depth limit\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp pm list --json # should list packages in JSON format\n[\n  {\n    \"name\": \"command-list-pnpm10\",\n    \"version\": \"1.0.0\",\n    \"path\": \"<cwd>\",\n    \"private\": false,\n    \"dependencies\": {\n      \"test-vite-plus-package-optional\": {\n        \"from\": \"test-vite-plus-package-optional\",\n        \"version\": \"1.0.0\",\n        \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/test-vite-plus-package-optional@<semver>/node_modules/test-vite-plus-package-optional\"\n      },\n      \"testnpm2\": {\n        \"from\": \"testnpm2\",\n        \"version\": \"1.0.1\",\n        \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\"\n      }\n    },\n    \"devDependencies\": {\n      \"test-vite-plus-package\": {\n        \"from\": \"test-vite-plus-package\",\n        \"version\": \"1.0.0\",\n        \"resolved\": \"https://registry.<domain>/test-vite-plus-package/-/test-vite-plus-package-1.0.0.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/test-vite-plus-package@<semver>/node_modules/test-vite-plus-package\"\n      }\n    }\n  }\n]\n\n> vp pm list --long # should list packages with extended info\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\n  just for snap-test\n  <cwd>/node_modules/.pnpm/test-vite-plus-package-optional@<semver>/node_modules/test-vite-plus-package-optional\ntestnpm2 <semver>\n  <cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\n\ndevDependencies:\ntest-vite-plus-package <semver>\n  just for snap-test\n  <cwd>/node_modules/.pnpm/test-vite-plus-package@<semver>/node_modules/test-vite-plus-package\n\n> vp pm list --parseable # should list packages in parseable format\n<cwd>\n<cwd>/node_modules/.pnpm/test-vite-plus-package@<semver>/node_modules/test-vite-plus-package\n<cwd>/node_modules/.pnpm/test-vite-plus-package-optional@<semver>/node_modules/test-vite-plus-package-optional\n<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\n\n> vp pm list --prod # should list production dependencies only\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\n> vp pm list --dev # should list development dependencies only\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp pm list --no-optional # should exclude optional dependencies\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp pm list --exclude-peers # should exclude peer dependencies\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp pm list --only-projects # should list only workspace projects (pnpm-specific)\n[1]> vp pm list --find-by customFinder # should use custom finder (pnpm-specific)\n ERR_PNPM_FINDER_NOT_FOUND  No finder with name customFinder is found\n\n> vp pm list --recursive # should list packages recursively in workspace\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp pm list -- --loglevel=warn # should support pass through arguments\nLegend: production dependency, optional only, dev only\n\ncommand-list-pnpm10@<semver> <cwd>\n\ndependencies:\ntest-vite-plus-package-optional <semver>\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm list --help # should show help\",\n    \"vp pm list # should list installed packages\",\n    \"vp pm list testnpm2 # should list specific package\",\n    \"vp pm list --depth 0 # should list packages with depth limit\",\n    \"vp pm list --json # should list packages in JSON format\",\n    \"vp pm list --long # should list packages with extended info\",\n    \"vp pm list --parseable # should list packages in parseable format\",\n    \"vp pm list --prod # should list production dependencies only\",\n    \"vp pm list --dev # should list development dependencies only\",\n    \"vp pm list --no-optional # should exclude optional dependencies\",\n    \"vp pm list --exclude-peers # should exclude peer dependencies\",\n    \"vp pm list --only-projects # should list only workspace projects (pnpm-specific)\",\n    \"vp pm list --find-by customFinder # should use custom finder (pnpm-specific)\",\n    \"vp pm list --recursive # should list packages recursively in workspace\",\n    \"vp pm list -- --loglevel=warn # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-list-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\"\n  },\n  \"peerDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10-with-workspace/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\nScope: all <variable> workspace projects\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\nDone in <variable>ms using pnpm v<semver>\n\n> vp pm list # should list current workspace root dependencies\n> vp pm list --recursive # should list all packages in workspace recursively\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\ntest-vite-plus-package-optional <semver>\n\n@vite-plus-test/utils@<semver> <cwd>/packages/utils (PRIVATE)\n\ndependencies:\ntestnpm2 <semver>\n\n> vp pm list --filter app # should list specific workspace package (uses --filter app list)\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\ntest-vite-plus-package-optional <semver>\n\n> vp pm list --filter app --filter @vite-plus-test/utils # should list multiple workspace packages\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\ntest-vite-plus-package-optional <semver>\n\n@vite-plus-test/utils@<semver> <cwd>/packages/utils (PRIVATE)\n\ndependencies:\ntestnpm2 <semver>\n\n> vp pm list --recursive --json # should list all workspace packages in JSON format\n[\n  {\n    \"name\": \"command-list-pnpm10-with-workspace\",\n    \"version\": \"1.0.0\",\n    \"path\": \"<cwd>\",\n    \"private\": false\n  },\n  {\n    \"name\": \"app\",\n    \"version\": \"1.0.0\",\n    \"path\": \"<cwd>/packages/app\",\n    \"private\": false,\n    \"dependencies\": {\n      \"@vite-plus-test/utils\": {\n        \"from\": \"@vite-plus-test/utils\",\n        \"version\": \"link:../utils\",\n        \"path\": \"<cwd>/packages/utils\"\n      },\n      \"test-vite-plus-package-optional\": {\n        \"from\": \"test-vite-plus-package-optional\",\n        \"version\": \"1.0.0\",\n        \"resolved\": \"https://registry.<domain>/test-vite-plus-package-optional/-/test-vite-plus-package-optional-1.0.0.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/test-vite-plus-package-optional@<semver>/node_modules/test-vite-plus-package-optional\"\n      }\n    }\n  },\n  {\n    \"name\": \"@vite-plus-test/utils\",\n    \"version\": \"1.0.0\",\n    \"path\": \"<cwd>/packages/utils\",\n    \"private\": true,\n    \"dependencies\": {\n      \"testnpm2\": {\n        \"from\": \"testnpm2\",\n        \"version\": \"1.0.1\",\n        \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\"\n      }\n    }\n  }\n]\n\n> vp pm list --recursive --depth 0 # should list workspace packages with depth limit\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\ntest-vite-plus-package-optional <semver>\n\n@vite-plus-test/utils@<semver> <cwd>/packages/utils (PRIVATE)\n\ndependencies:\ntestnpm2 <semver>\n\n> vp pm list --recursive --only-projects # should list only workspace projects (pnpm-specific)\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\n\n> vp pm list --recursive --exclude-peers # should exclude peer dependencies in workspace\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\ntest-vite-plus-package-optional <semver>\n\n@vite-plus-test/utils@<semver> <cwd>/packages/utils (PRIVATE)\n\ndependencies:\ntestnpm2 <semver>\n\n> vp pm list --recursive --prod # should list production dependencies in workspace\nLegend: production dependency, optional only, dev only\n\napp@<semver> <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\ntest-vite-plus-package-optional <semver>\n\n@vite-plus-test/utils@<semver> <cwd>/packages/utils (PRIVATE)\n\ndependencies:\ntestnpm2 <semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm list # should list current workspace root dependencies\",\n    \"vp pm list --recursive # should list all packages in workspace recursively\",\n    \"vp pm list --filter app # should list specific workspace package (uses --filter app list)\",\n    \"vp pm list --filter app --filter @vite-plus-test/utils # should list multiple workspace packages\",\n    \"vp pm list --recursive --json # should list all workspace packages in JSON format\",\n    \"vp pm list --recursive --depth 0 # should list workspace packages with depth limit\",\n    \"vp pm list --recursive --only-projects # should list only workspace projects (pnpm-specific)\",\n    \"vp pm list --recursive --exclude-peers # should exclude peer dependencies in workspace\",\n    \"vp pm list --recursive --prod # should list production dependencies in workspace\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-yarn1/package.json",
    "content": "{\n  \"name\": \"command-list-yarn1\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@1.22.22\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-yarn1/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\nyarn install v<semver>\ninfo No lockfile found.\n[1/4] Resolving packages...\n[2/4] Fetching packages...\n[3/4] Linking dependencies...\n[4/4] Building fresh packages...\nsuccess Saved lockfile.\nDone in <variable>ms.\n\n> vp pm list # should list installed packages\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list testnpm2 # should list specific package\nyarn list v<semver>\nwarning Filtering by arguments is deprecated. Please use the pattern option instead.\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --depth 0 # should list packages with depth limit\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --json # should list packages in JSON format\n{\"type\":\"warning\",\"data\":\"package.json: No license field\"}\n{\"type\":\"warning\",\"data\":\"command-list-yarn1@<semver>: No license field\"}\n{\"type\":\"tree\",\"data\":{\"type\":\"list\",\"trees\":[{\"name\":\"testnpm2@<semver>\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0},{\"name\":\"test-vite-plus-package@<semver>\",\"children\":[],\"hint\":null,\"color\":\"bold\",\"depth\":0}]}}\n\n> vp pm list --prod # should show warning that --prod not supported by yarn@1\nwarn: yarn@1 does not support --prod, ignoring --prod flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --dev # should show warning that --dev not supported by yarn@1\nwarn: yarn@1 does not support --dev, ignoring --dev flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --no-optional # should show warning that --no-optional not supported by yarn@1\nwarn: yarn@1 does not support --no-optional, ignoring --no-optional flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --exclude-peers # should show warning that --exclude-peers not supported by yarn@1\nwarn: yarn@1 does not support --exclude-peers, ignoring flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --only-projects # should show warning that --only-projects not supported by yarn@1\nwarn: yarn@1 does not support --only-projects, ignoring flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --find-by customFinder # should show warning that --find-by not supported by yarn@1\nwarn: yarn@1 does not support --find-by, ignoring flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --recursive # should show warning that --recursive not supported by yarn@1\nwarn: yarn@1 does not support --recursive, ignoring --recursive flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list --filter app # should show warning that --filter not supported by yarn@1\nwarn: yarn@1 does not support --filter, ignoring --filter flag\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n\n> vp pm list -- --loglevel=warn # should support pass through arguments\nyarn list v<semver>\n├─ test-vite-plus-package@<semver>\n└─ testnpm2@<semver>\nDone in <variable>ms.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-yarn1/steps.json",
    "content": "{\n  \"env\": {\n    \"NODE_OPTIONS\": \"--no-deprecation\"\n  },\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm list # should list installed packages\",\n    \"vp pm list testnpm2 # should list specific package\",\n    \"vp pm list --depth 0 # should list packages with depth limit\",\n    \"vp pm list --json # should list packages in JSON format\",\n    \"vp pm list --prod # should show warning that --prod not supported by yarn@1\",\n    \"vp pm list --dev # should show warning that --dev not supported by yarn@1\",\n    \"vp pm list --no-optional # should show warning that --no-optional not supported by yarn@1\",\n    \"vp pm list --exclude-peers # should show warning that --exclude-peers not supported by yarn@1\",\n    \"vp pm list --only-projects # should show warning that --only-projects not supported by yarn@1\",\n    \"vp pm list --find-by customFinder # should show warning that --find-by not supported by yarn@1\",\n    \"vp pm list --recursive # should show warning that --recursive not supported by yarn@1\",\n    \"vp pm list --filter app # should show warning that --filter not supported by yarn@1\",\n    \"vp pm list -- --loglevel=warn # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-yarn4/package.json",
    "content": "{\n  \"name\": \"command-list-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-yarn4/snap.txt",
    "content": "> vp pm list # should show warning that yarn@2+ does not support list command\nwarn: yarn@2+ does not support 'list' command\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-list-yarn4/steps.json",
    "content": "{\n  \"commands\": [\"vp pm list # should show warning that yarn@2+ does not support list command\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10/package.json",
    "content": "{\n  \"name\": \"command-outdated-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-top-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 4 packages in <variable>ms\n\n[1]> vp outdated testnpm2 # should outdated package\nPackage   Current  Wanted  Latest  Location               Depended by\ntestnpm2    <semver>   <semver>   <semver>  node_modules/testnpm2  command-outdated-npm10\n\n> vp outdated test-vite* # should outdated with glob pattern not working on npm\n[1]> vp outdated --format json # should support json output\n{\n  \"test-vite-plus-other-optional\": {\n    \"current\": \"1.0.0\",\n    \"wanted\": \"1.0.0\",\n    \"latest\": \"1.1.0\",\n    \"dependent\": \"command-outdated-npm10\",\n    \"location\": \"<cwd>/node_modules/test-vite-plus-other-optional\"\n  },\n  \"test-vite-plus-top-package\": {\n    \"current\": \"1.0.0\",\n    \"wanted\": \"1.0.0\",\n    \"latest\": \"1.1.0\",\n    \"dependent\": \"command-outdated-npm10\",\n    \"location\": \"<cwd>/node_modules/test-vite-plus-top-package\"\n  },\n  \"testnpm2\": {\n    \"current\": \"1.0.0\",\n    \"wanted\": \"1.0.0\",\n    \"latest\": \"1.0.1\",\n    \"dependent\": \"command-outdated-npm10\",\n    \"location\": \"<cwd>/node_modules/testnpm2\"\n  }\n}\n\n[1]> vp outdated --format list # should support list output\n<cwd>/node_modules/test-vite-plus-other-optional:test-vite-plus-other-optional@<semver>:test-vite-plus-other-optional@<semver>:test-vite-plus-other-optional@<semver>:command-outdated-npm10\n<cwd>/node_modules/test-vite-plus-top-package:test-vite-plus-top-package@<semver>:test-vite-plus-top-package@<semver>:test-vite-plus-top-package@<semver>:command-outdated-npm10\n<cwd>/node_modules/testnpm2:testnpm2@<semver>:testnpm2@<semver>:testnpm2@<semver>:command-outdated-npm10\n\n[1]> vp outdated --format table # should support table output\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> vp outdated testnpm2 --long # should support --long\nPackage   Current  Wanted  Latest  Location               Depended by             Package Type  Homepage\ntestnpm2    <semver>   <semver>   <semver>  node_modules/testnpm2  command-outdated-npm10  dependencies\n\n[1]> vp outdated -r # should support recursive output\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> vp outdated -P # should support prod output\nwarn: --prod/--dev not supported by npm\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> vp outdated -D # should support dev output\nwarn: --prod/--dev not supported by npm\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> vp outdated --no-optional # should support no-optional output\nwarn: --no-optional not supported by npm\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> vp outdated --compatible # should compatible output nothing\nwarn: --compatible not supported by npm\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> json-edit package.json '_.optionalDependencies[\"test-vite-plus-other-optional\"] = \"^1.0.0\"' && vp outdated --compatible # should support compatible output with optional dependencies\nwarn: --compatible not supported by npm\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n\n[1]> vp outdated --sort-by name # should support sort-by output\nwarn: --sort-by not supported by npm\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  command-outdated-npm10\ntest-vite-plus-top-package       <semver>   <semver>   <semver>  node_modules/test-vite-plus-top-package     command-outdated-npm10\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp outdated testnpm2 # should outdated package\",\n    \"vp outdated test-vite* # should outdated with glob pattern not working on npm\",\n    \"vp outdated --format json # should support json output\",\n    \"vp outdated --format list # should support list output\",\n    \"vp outdated --format table # should support table output\",\n    \"vp outdated testnpm2 --long # should support --long\",\n    \"vp outdated -r # should support recursive output\",\n    \"vp outdated -P # should support prod output\",\n    \"vp outdated -D # should support dev output\",\n    \"vp outdated --no-optional # should support no-optional output\",\n    \"vp outdated --compatible # should compatible output nothing\",\n    \"json-edit package.json '_.optionalDependencies[\\\"test-vite-plus-other-optional\\\"] = \\\"^1.0.0\\\"' && vp outdated --compatible # should support compatible output with optional dependencies\",\n    \"vp outdated --sort-by name # should support sort-by output\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-outdated-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.8.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"^1.0.0\",\n    \"test-vite-plus-install\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {}\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10-with-workspace/snap.txt",
    "content": "> vp install\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 6 packages in <variable>ms\n\n[1]> vp outdated testnpm2 -w # should outdated in workspace root\nPackage   Current  Wanted  Latest  Location               Depended by\ntestnpm2    <semver>   <semver>   <semver>  node_modules/testnpm2  command-outdated-npm10-with-workspace\n\n> vp outdated testnpm2 --filter app # should outdated in specific package\n[1]> vp outdated --filter \"*\" --format json # should outdated in all packages\n{\n  \"test-vite-plus-other-optional\": {\n    \"current\": \"1.0.0\",\n    \"wanted\": \"1.0.0\",\n    \"latest\": \"1.1.0\",\n    \"dependent\": \"app\",\n    \"location\": \"<cwd>/node_modules/test-vite-plus-other-optional\"\n  }\n}\n\n[1]> vp outdated -r # should outdated recursively\nPackage                        Current  Wanted  Latest  Location                                    Depended by\ntest-vite-plus-other-optional    <semver>   <semver>   <semver>  node_modules/test-vite-plus-other-optional  app@\ntestnpm2                         <semver>   <semver>   <semver>  node_modules/testnpm2                       command-outdated-npm10-with-workspace\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-npm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install\",\n    \"vp outdated testnpm2 -w # should outdated in workspace root\",\n    \"vp outdated testnpm2 --filter app # should outdated in specific package\",\n    \"vp outdated --filter \\\"*\\\" --format json # should outdated in all packages\",\n    \"vp outdated -r # should outdated recursively\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-outdated-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-top-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10/snap.txt",
    "content": "> vp outdated --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp outdated [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]\n\nCheck for outdated packages\n\nArguments:\n  [PACKAGES]...           Package name(s) to check\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --long                Show extended information\n  --format <FORMAT>     Output format: table (default), list, or json\n  -r, --recursive       Check recursively across all workspaces\n  --filter <PATTERN>    Filter packages in monorepo\n  -w, --workspace-root  Include workspace root\n  -P, --prod            Only production and optional dependencies\n  -D, --dev             Only dev dependencies\n  --no-optional         Exclude optional dependencies\n  --compatible          Only show compatible versions\n  --sort-by <FIELD>     Sort results by field\n  -g, --global          Check globally installed packages\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver> (1.0.1 is available)\n\noptionalDependencies:\n+ test-vite-plus-other-optional <semver> (1.1.0 is available)\n\ndevDependencies:\n+ test-vite-plus-top-package <semver> (1.1.0 is available)\n\nDone in <variable>ms using pnpm v<semver>\n\n[1]> vp outdated testnpm2 # should outdated package\n┌──────────┬─────────┬────────┐\n│ Package  │ Current │ Latest │\n├──────────┼─────────┼────────┤\n│ testnpm2 │ <semver>   │ <semver>  │\n└──────────┴─────────┴────────┘\n\n[1]> vp outdated test-vite* # should outdated with one glob pattern\n┌──────────────────────────────────────────┬─────────┬────────┐\n│ Package                                  │ Current │ Latest │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-top-package (dev)         │ <semver>   │ <semver>  │\n└──────────────────────────────────────────┴─────────┴────────┘\n\n[1]> vp outdated test-vite* '*npm*' # should outdated with multiple glob patterns\n┌──────────────────────────────────────────┬─────────┬────────┐\n│ Package                                  │ Current │ Latest │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ testnpm2                                 │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-top-package (dev)         │ <semver>   │ <semver>  │\n└──────────────────────────────────────────┴─────────┴────────┘\n\n[1]> vp outdated --format json # should support json output\n{\n  \"testnpm2\": {\n    \"current\": \"1.0.0\",\n    \"latest\": \"1.0.1\",\n    \"wanted\": \"1.0.0\",\n    \"isDeprecated\": false,\n    \"dependencyType\": \"dependencies\"\n  },\n  \"test-vite-plus-other-optional\": {\n    \"current\": \"1.0.0\",\n    \"latest\": \"1.1.0\",\n    \"wanted\": \"1.0.0\",\n    \"isDeprecated\": false,\n    \"dependencyType\": \"optionalDependencies\"\n  },\n  \"test-vite-plus-top-package\": {\n    \"current\": \"1.0.0\",\n    \"latest\": \"1.1.0\",\n    \"wanted\": \"1.0.0\",\n    \"isDeprecated\": false,\n    \"dependencyType\": \"devDependencies\"\n  }\n}\n\n[1]> vp outdated --format list # should support list output\ntestnpm2\n<semver> => <semver>\n\ntest-vite-plus-other-optional (optional)\n<semver> => <semver>\n\ntest-vite-plus-top-package (dev)\n<semver> => <semver>\n\n[1]> vp outdated --format table # should support table output\n┌──────────────────────────────────────────┬─────────┬────────┐\n│ Package                                  │ Current │ Latest │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ testnpm2                                 │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-top-package (dev)         │ <semver>   │ <semver>  │\n└──────────────────────────────────────────┴─────────┴────────┘\n\n[1]> vp outdated testnpm2 --long --format list # should support --long\ntestnpm2\n<semver> => <semver>\n\n[1]> vp outdated -r # should support recursive output\n┌──────────────────────────────────────────┬─────────┬────────┬─────────────────────────┐\n│ Package                                  │ Current │ Latest │ Dependents              │\n├──────────────────────────────────────────┼─────────┼────────┼─────────────────────────┤\n│ testnpm2                                 │ <semver>   │ <semver>  │ command-outdated-pnpm10 │\n├──────────────────────────────────────────┼─────────┼────────┼─────────────────────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │ command-outdated-pnpm10 │\n├──────────────────────────────────────────┼─────────┼────────┼─────────────────────────┤\n│ test-vite-plus-top-package (dev)         │ <semver>   │ <semver>  │ command-outdated-pnpm10 │\n└──────────────────────────────────────────┴─────────┴────────┴─────────────────────────┘\n\n[1]> vp outdated -P # should support prod output\n┌──────────────────────────────────────────┬─────────┬────────┐\n│ Package                                  │ Current │ Latest │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ testnpm2                                 │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │\n└──────────────────────────────────────────┴─────────┴────────┘\n\n[1]> vp outdated -D # should support dev output\n┌──────────────────────────────────┬─────────┬────────┐\n│ Package                          │ Current │ Latest │\n├──────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-top-package (dev) │ <semver>   │ <semver>  │\n└──────────────────────────────────┴─────────┴────────┘\n\n[1]> vp outdated --no-optional # should support no-optional output\n┌──────────────────────────────────┬─────────┬────────┐\n│ Package                          │ Current │ Latest │\n├──────────────────────────────────┼─────────┼────────┤\n│ testnpm2                         │ <semver>   │ <semver>  │\n├──────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-top-package (dev) │ <semver>   │ <semver>  │\n└──────────────────────────────────┴─────────┴────────┘\n\n> vp outdated --compatible # should compatible output nothing\n[1]> json-edit package.json '_.optionalDependencies[\"test-vite-plus-other-optional\"] = \"^1.0.0\"' && vp outdated --compatible # should support compatible output with optional dependencies\n┌──────────────────────────────────────────┬─────────┬────────┐\n│ Package                                  │ Current │ Latest │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │\n└──────────────────────────────────────────┴─────────┴────────┘\n\n[1]> vp outdated --sort-by name # should support sort-by output\n┌──────────────────────────────────────────┬─────────┬────────┐\n│ Package                                  │ Current │ Latest │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ test-vite-plus-top-package (dev)         │ <semver>   │ <semver>  │\n├──────────────────────────────────────────┼─────────┼────────┤\n│ testnpm2                                 │ <semver>   │ <semver>  │\n└──────────────────────────────────────────┴─────────┴────────┘\n\n> vp outdated testnpm2 -g --format json # should support global output\n{}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp outdated --help # should show help\",\n    \"vp install # should install packages first\",\n    \"vp outdated testnpm2 # should outdated package\",\n    \"vp outdated test-vite* # should outdated with one glob pattern\",\n    \"vp outdated test-vite* '*npm*' # should outdated with multiple glob patterns\",\n    \"vp outdated --format json # should support json output\",\n    \"vp outdated --format list # should support list output\",\n    \"vp outdated --format table # should support table output\",\n    \"vp outdated testnpm2 --long --format list # should support --long\",\n    \"vp outdated -r # should support recursive output\",\n    \"vp outdated -P # should support prod output\",\n    \"vp outdated -D # should support dev output\",\n    \"vp outdated --no-optional # should support no-optional output\",\n    \"vp outdated --compatible # should compatible output nothing\",\n    \"json-edit package.json '_.optionalDependencies[\\\"test-vite-plus-other-optional\\\"] = \\\"^1.0.0\\\"' && vp outdated --compatible # should support compatible output with optional dependencies\",\n    \"vp outdated --sort-by name # should support sort-by output\",\n    \"vp outdated testnpm2 -g --format json # should support global output\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-outdated-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10-with-workspace/snap.txt",
    "content": "> vp install\nVITE+ - The Unified Toolchain for the Web\n\nScope: all <variable> workspace projects\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver> (1.0.1 is available)\n\nDone in <variable>ms using pnpm v<semver>\n\n[1]> vp outdated testnpm2 -w # should outdated in workspace root\n┌──────────┬─────────┬────────┐\n│ Package  │ Current │ Latest │\n├──────────┼─────────┼────────┤\n│ testnpm2 │ <semver>   │ <semver>  │\n└──────────┴─────────┴────────┘\n\n[1]> vp outdated testnpm2 --filter app # should outdated in specific package\n┌──────────┬─────────┬────────┬────────────┐\n│ Package  │ Current │ Latest │ Dependents │\n├──────────┼─────────┼────────┼────────────┤\n│ testnpm2 │ <semver>   │ <semver>  │ app        │\n└──────────┴─────────┴────────┴────────────┘\n\n> vp outdated -D --filter app # should outdated dev dependencies in app\n[1]> vp outdated --filter \"*\" --format json # should outdated in all packages\n{\n  \"testnpm2\": {\n    \"current\": \"1.0.0\",\n    \"latest\": \"1.0.1\",\n    \"wanted\": \"1.0.0\",\n    \"isDeprecated\": false,\n    \"dependencyType\": \"dependencies\",\n    \"dependentPackages\": [\n      {\n        \"name\": \"command-outdated-pnpm10-with-workspace\",\n        \"location\": \"<cwd>\"\n      },\n      {\n        \"name\": \"app\",\n        \"location\": \"<cwd>/packages/app\"\n      },\n      {\n        \"name\": \"@vite-plus-test/utils\",\n        \"location\": \"<cwd>/packages/utils\"\n      }\n    ]\n  },\n  \"test-vite-plus-other-optional\": {\n    \"current\": \"1.0.0\",\n    \"latest\": \"1.1.0\",\n    \"wanted\": \"1.0.0\",\n    \"isDeprecated\": false,\n    \"dependencyType\": \"optionalDependencies\",\n    \"dependentPackages\": [\n      {\n        \"name\": \"app\",\n        \"location\": \"<cwd>/packages/app\"\n      }\n    ]\n  }\n}\n\n[1]> vp outdated -r # should outdated recursively\n┌──────────────────────────────────────────┬─────────┬────────┬────────────────────────────────┐\n│ Package                                  │ Current │ Latest │ Dependents                     │\n├──────────────────────────────────────────┼─────────┼────────┼────────────────────────────────┤\n│ testnpm2                                 │ <semver>   │ <semver>  │ @vite-plus-test/utils, app,    │\n│                                          │         │        │ command-outdated-pnpm10-with-  │\n│                                          │         │        │ workspace                      │\n├──────────────────────────────────────────┼─────────┼────────┼────────────────────────────────┤\n│ test-vite-plus-other-optional (optional) │ <semver>   │ <semver>  │ app                            │\n└──────────────────────────────────────────┴─────────┴────────┴────────────────────────────────┘\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install\",\n    \"vp outdated testnpm2 -w # should outdated in workspace root\",\n    \"vp outdated testnpm2 --filter app # should outdated in specific package\",\n    \"vp outdated -D --filter app # should outdated dev dependencies in app\",\n    \"vp outdated --filter \\\"*\\\" --format json # should outdated in all packages\",\n    \"vp outdated -r # should outdated recursively\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-yarn4/package.json",
    "content": "{\n  \"name\": \"command-outdated-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-top-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@4.10.1\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-yarn4/snap.txt",
    "content": "> vp outdated -- -h # should show yarn upgrade-interactive help\nnote: yarn@2+ uses 'yarn upgrade-interactive' for checking outdated packages\nOpen the upgrade interface\n\nUsage\n\n$ yarn upgrade-interactive\n\nDetails\n\nThis command opens a fullscreen terminal interface where you can see any out of \ndate packages used by your application, their status compared to the latest \nversions available on the remote registry, and select packages to upgrade.\n\nExamples\n\nOpen the upgrade window\n  $ yarn upgrade-interactive\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-outdated-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp outdated -- -h # should show yarn upgrade-interactive help\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-npm10/package.json",
    "content": "{\n  \"name\": \"command-owner-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-npm10/snap.txt",
    "content": "> vp pm owner list testnpm2 # should list package owners\nfengmk2 <fengmk2@gmail.com>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-npm10/steps.json",
    "content": "{\n  \"commands\": [\"vp pm owner list testnpm2 # should list package owners\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-owner-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-pnpm10/snap.txt",
    "content": "> vp pm owner --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm owner <COMMAND>\n\nManage package owners\n\nCommands:\n  list  List package owners [aliases: ls]\n  add   Add package owner\n  rm    Remove package owner\n\nOptions:\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm owner list testnpm2 # should list package owners (uses npm owner)\nfengmk2 <fengmk2@gmail.com>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm owner --help # should show help\",\n    \"vp pm owner list testnpm2 # should list package owners (uses npm owner)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-yarn1/package.json",
    "content": "{\n  \"name\": \"command-owner-yarn1\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@1.22.22\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-yarn1/snap.txt",
    "content": "> vp pm owner list testnpm2 # should list package owners (uses npm owner)\nfengmk2 <fengmk2@gmail.com>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-yarn1/steps.json",
    "content": "{\n  \"commands\": [\"vp pm owner list testnpm2 # should list package owners (uses npm owner)\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-yarn4/package.json",
    "content": "{\n  \"name\": \"command-owner-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-yarn4/snap.txt",
    "content": "> vp pm owner list testnpm2 # should list package owners (uses npm owner)\nfengmk2 <fengmk2@gmail.com>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-owner-yarn4/steps.json",
    "content": "{\n  \"commands\": [\"vp pm owner list testnpm2 # should list package owners (uses npm owner)\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe/package.json",
    "content": "{\n  \"name\": \"command-pack-exe\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \"25.7.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe/snap.txt",
    "content": "> vp pack src/index.ts --exe\nVITE+ - The Unified Toolchain for the Web\n\nℹ entry: src/index.ts\nℹ target: node25.7.0\nℹ `exe` option is experimental and may change in future releases.\nℹ Build start\nℹ dist/index.mjs  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\nℹ build/index  <variable> MB\n✔ Built executable: build/index (<variable>ms)\n\n> ls dist\nindex.mjs\n\n> ls build\nindex\n\n> ./build/index\nhello from exe\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe/src/index.ts",
    "content": "console.log('hello from exe');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp pack src/index.ts --exe\", \"ls dist\", \"ls build\", \"./build/index\"],\n  \"after\": [\"rm -rf dist\", \"rm -rf build\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe/vite.config.ts",
    "content": "export default {};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe-error/package.json",
    "content": "{\n  \"name\": \"command-pack-exe-error\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \"22.22.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe-error/snap.txt",
    "content": "[1]> vp pack src/index.ts --exe\nVITE+ - The Unified Toolchain for the Web\n\nℹ entry: src/index.ts\nℹ target: node22.22.0\nerror: Node.js version v<semver> does not support `exe` option. Please upgrade to Node.js <semver> or later.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe-error/src/index.ts",
    "content": "export const hello = 'world';\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe-error/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\"vp pack src/index.ts --exe\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-exe-error/vite.config.ts",
    "content": "export default {};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10/package.json",
    "content": "{\n  \"name\": \"command-pack-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10/snap.txt",
    "content": "> vp pm pack --json && rm -rf *.tgz # should pack current package\n[\n  {\n    \"id\": \"command-pack-npm10@<semver>\",\n    \"name\": \"command-pack-npm10\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"command-pack-npm10-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"output.log\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"snap.txt\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"steps.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 4,\n    \"bundled\": []\n  }\n]\n\n> vp pm pack --pack-destination ./dist --json && rm -rf ./dist # should pack with destination\n[\n  {\n    \"id\": \"command-pack-npm10@<semver>\",\n    \"name\": \"command-pack-npm10\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"command-pack-npm10-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"command-pack-npm10-1.0.0.tgz\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"output.log\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"snap.txt\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"steps.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 5,\n    \"bundled\": []\n  }\n]\n\n> vp pm pack --json -- --loglevel=warn && rm -rf *.tgz # should support pass through arguments\n[\n  {\n    \"id\": \"command-pack-npm10@<semver>\",\n    \"name\": \"command-pack-npm10\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"command-pack-npm10-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"command-pack-npm10-1.0.0.tgz\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"output.log\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"snap.txt\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"steps.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 5,\n    \"bundled\": []\n  }\n]\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pm pack --json && rm -rf *.tgz # should pack current package\",\n    \"vp pm pack --pack-destination ./dist --json && rm -rf ./dist # should pack with destination\",\n    \"vp pm pack --json -- --loglevel=warn && rm -rf *.tgz # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-pack-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt",
    "content": "> vp pm pack --json # should pack current workspace root\n[\n  {\n    \"id\": \"command-pack-npm10-with-workspace@<semver>\",\n    \"name\": \"command-pack-npm10-with-workspace\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"command-pack-npm10-with-workspace-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"output.log\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"packages/app/package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"packages/utils/package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"snap.txt\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"steps.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 6,\n    \"bundled\": []\n  }\n]\n\n> vp pm pack --recursive --json && rm -rf *.tgz # should pack all packages in workspace (uses --workspaces)\n[\n  {\n    \"id\": \"app@<semver>\",\n    \"name\": \"app\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"app-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 1,\n    \"bundled\": []\n  },\n  {\n    \"id\": \"@vite-plus-test/utils@<semver>\",\n    \"name\": \"@vite-plus-test/utils\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"vite-plus-test-utils-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 1,\n    \"bundled\": []\n  }\n]\n\n> vp pm pack --filter app --json && rm -rf *.tgz # should pack specific package (uses --workspace app)\n[\n  {\n    \"id\": \"app@<semver>\",\n    \"name\": \"app\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"app-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 1,\n    \"bundled\": []\n  }\n]\n\n> vp pm pack --filter app --filter @vite-plus-test/utils --json && rm -rf *.tgz # should pack multiple packages\n[\n  {\n    \"id\": \"app@<semver>\",\n    \"name\": \"app\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"app-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 1,\n    \"bundled\": []\n  },\n  {\n    \"id\": \"@vite-plus-test/utils@<semver>\",\n    \"name\": \"@vite-plus-test/utils\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"vite-plus-test-utils-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 1,\n    \"bundled\": []\n  }\n]\n\n> vp pm pack --pack-destination ./dist --json && rm -rf ./dist # should pack with destination\n[\n  {\n    \"id\": \"command-pack-npm10-with-workspace@<semver>\",\n    \"name\": \"command-pack-npm10-with-workspace\",\n    \"version\": \"1.0.0\",\n    \"size\": <variable>,\n    \"unpackedSize\": <variable>,\n    \"shasum\": \"<hash>\",\n    \"integrity\": \"sha512-<hash>\",\n    \"filename\": \"command-pack-npm10-with-workspace-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"app-1.0.0.tgz\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"command-pack-npm10-with-workspace-1.0.0.tgz\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"output.log\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"packages/app/package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"packages/utils/package.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"snap.txt\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"steps.json\",\n        \"size\": <variable>,\n        \"mode\": 420\n      },\n      {\n        \"path\": \"vite-plus-test-utils-1.0.0.tgz\",\n        \"size\": <variable>,\n        \"mode\": 420\n      }\n    ],\n    \"entryCount\": 9,\n    \"bundled\": []\n  }\n]\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-npm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pm pack --json # should pack current workspace root\",\n    \"vp pm pack --recursive --json && rm -rf *.tgz # should pack all packages in workspace (uses --workspaces)\",\n    \"vp pm pack --filter app --json && rm -rf *.tgz # should pack specific package (uses --workspace app)\",\n    \"vp pm pack --filter app --filter @vite-plus-test/utils --json && rm -rf *.tgz # should pack multiple packages\",\n    \"vp pm pack --pack-destination ./dist --json && rm -rf ./dist # should pack with destination\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-pack-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt",
    "content": "> vp pm pack --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm pack [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nCreate a tarball of the package\n\nArguments:\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  -r, --recursive                        Pack all workspace packages\n  --filter <PATTERN>                     Filter packages to pack\n  --out <OUT>                            Output path for the tarball\n  --pack-destination <PACK_DESTINATION>  Directory where the tarball will be saved\n  --pack-gzip-level <PACK_GZIP_LEVEL>    Gzip compression level (0-9)\n  --json                                 Output in JSON format\n  -h, --help                             Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm pack && rm -rf *.tgz # should pack current package\n📦  command-pack-pnpm10@<semver>\nTarball Contents\noutput.log\npackage.json\nsnap.txt\nsteps.json\nTarball Details\ncommand-pack-pnpm10-1.0.0.tgz\n\n> vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file\n📦  command-pack-pnpm10@<semver>\nTarball Contents\ncommand-pack-pnpm10-1.0.0.tgz\noutput.log\npackage.json\nsnap.txt\nsteps.json\nTarball Details\n<cwd>/dist/package.tgz\n\n> vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination\n📦  command-pack-pnpm10@<semver>\nTarball Contents\ncommand-pack-pnpm10-1.0.0.tgz\noutput.log\npackage.json\nsnap.txt\nsteps.json\nTarball Details\n<cwd>/dist/command-pack-pnpm10-1.0.0.tgz\n\n> vp pm pack --json --pack-gzip-level 9 && rm -rf *.tgz # should pack with gzip compression level\n{\n  \"name\": \"command-pack-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"filename\": \"command-pack-pnpm10-1.0.0.tgz\",\n  \"files\": [\n    {\n      \"path\": \"command-pack-pnpm10-1.0.0.tgz\"\n    },\n    {\n      \"path\": \"output.log\"\n    },\n    {\n      \"path\": \"package.json\"\n    },\n    {\n      \"path\": \"snap.txt\"\n    },\n    {\n      \"path\": \"steps.json\"\n    }\n  ]\n}\n\n> vp pm pack --json && rm -rf *.tgz # should pack with json output\n{\n  \"name\": \"command-pack-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"filename\": \"command-pack-pnpm10-1.0.0.tgz\",\n  \"files\": [\n    {\n      \"path\": \"command-pack-pnpm10-1.0.0.tgz\"\n    },\n    {\n      \"path\": \"output.log\"\n    },\n    {\n      \"path\": \"package.json\"\n    },\n    {\n      \"path\": \"snap.txt\"\n    },\n    {\n      \"path\": \"steps.json\"\n    }\n  ]\n}\n\n> vp pm pack -- --loglevel=warn && rm -rf *.tgz # should support pass through arguments\n📦  command-pack-pnpm10@<semver>\nTarball Contents\ncommand-pack-pnpm10-1.0.0.tgz\noutput.log\npackage.json\nsnap.txt\nsteps.json\nTarball Details\ncommand-pack-pnpm10-1.0.0.tgz\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pm pack --help # should show help\",\n    \"vp pm pack && rm -rf *.tgz # should pack current package\",\n    \"vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file\",\n    \"vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination\",\n    \"vp pm pack --json --pack-gzip-level 9 && rm -rf *.tgz # should pack with gzip compression level\",\n    \"vp pm pack --json && rm -rf *.tgz # should pack with json output\",\n    \"vp pm pack -- --loglevel=warn && rm -rf *.tgz # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-pack-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt",
    "content": "> vp pm pack && rm -rf *.tgz # should pack current workspace root\n📦  command-pack-pnpm10-with-workspace@<semver>\nTarball Contents\noutput.log\npackage.json\npackages/app/package.json\npackages/utils/package.json\npnpm-workspace.yaml\nsnap.txt\nsteps.json\nTarball Details\ncommand-pack-pnpm10-with-workspace-1.0.0.tgz\n\n> vp pm pack --recursive --json > out.json && tool json-sort out.json '_.name' && cat out.json && rm -rf *.tgz # should pack all packages in workspace\n[\n  {\n    \"name\": \"@vite-plus-test/utils\",\n    \"version\": \"1.0.0\",\n    \"filename\": \"<cwd>/vite-plus-test-utils-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\"\n      }\n    ]\n  },\n  {\n    \"name\": \"app\",\n    \"version\": \"1.0.0\",\n    \"filename\": \"<cwd>/app-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\"\n      }\n    ]\n  },\n  {\n    \"name\": \"command-pack-pnpm10-with-workspace\",\n    \"version\": \"1.0.0\",\n    \"filename\": \"command-pack-pnpm10-with-workspace-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"command-pack-pnpm10-with-workspace-1.0.0.tgz\"\n      },\n      {\n        \"path\": \"out.json\"\n      },\n      {\n        \"path\": \"output.log\"\n      },\n      {\n        \"path\": \"package.json\"\n      },\n      {\n        \"path\": \"packages/app/package.json\"\n      },\n      {\n        \"path\": \"packages/utils/package.json\"\n      },\n      {\n        \"path\": \"pnpm-workspace.yaml\"\n      },\n      {\n        \"path\": \"snap.txt\"\n      },\n      {\n        \"path\": \"steps.json\"\n      }\n    ]\n  }\n]\n\n> vp pm pack --filter app && rm -rf *.tgz # should pack specific package (uses --filter app pack)\n📦  app@<semver>\nTarball Contents\npackage.json\nTarball Details\n<cwd>/app-1.0.0.tgz\n\n> vp pm pack --filter app --filter @vite-plus-test/utils --json > out.json && tool json-sort out.json '_.name' && cat out.json && rm -rf *.tgz # should pack multiple packages\n[\n  {\n    \"name\": \"@vite-plus-test/utils\",\n    \"version\": \"1.0.0\",\n    \"filename\": \"<cwd>/vite-plus-test-utils-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\"\n      }\n    ]\n  },\n  {\n    \"name\": \"app\",\n    \"version\": \"1.0.0\",\n    \"filename\": \"<cwd>/app-1.0.0.tgz\",\n    \"files\": [\n      {\n        \"path\": \"package.json\"\n      }\n    ]\n  }\n]\n\n> vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file\n📦  command-pack-pnpm10-with-workspace@<semver>\nTarball Contents\napp-1.0.0.tgz\ncommand-pack-pnpm10-with-workspace-1.0.0.tgz\nout.json\noutput.log\npackage.json\npackages/app/package.json\npackages/utils/package.json\npnpm-workspace.yaml\nsnap.txt\nsteps.json\nvite-plus-test-utils-1.0.0.tgz\nTarball Details\n<cwd>/dist/package.tgz\n\n> vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination\n📦  command-pack-pnpm10-with-workspace@<semver>\nTarball Contents\napp-1.0.0.tgz\ncommand-pack-pnpm10-with-workspace-1.0.0.tgz\nout.json\noutput.log\npackage.json\npackages/app/package.json\npackages/utils/package.json\npnpm-workspace.yaml\nsnap.txt\nsteps.json\nvite-plus-test-utils-1.0.0.tgz\nTarball Details\n<cwd>/dist/command-pack-pnpm10-with-workspace-1.0.0.tgz\n\n> vp pm pack --pack-gzip-level 9 && rm -rf *.tgz # should pack with gzip compression level\n📦  command-pack-pnpm10-with-workspace@<semver>\nTarball Contents\napp-1.0.0.tgz\ncommand-pack-pnpm10-with-workspace-1.0.0.tgz\nout.json\noutput.log\npackage.json\npackages/app/package.json\npackages/utils/package.json\npnpm-workspace.yaml\nsnap.txt\nsteps.json\nvite-plus-test-utils-1.0.0.tgz\nTarball Details\ncommand-pack-pnpm10-with-workspace-1.0.0.tgz\n\n> vp pm pack --json --out 'foo-%s-%v.tgz' && rm -rf *.tgz # should pack with json output\n{\n  \"name\": \"command-pack-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"filename\": \"foo-command-pack-pnpm10-with-workspace-1.0.0.tgz\",\n  \"files\": [\n    {\n      \"path\": \"app-1.0.0.tgz\"\n    },\n    {\n      \"path\": \"command-pack-pnpm10-with-workspace-1.0.0.tgz\"\n    },\n    {\n      \"path\": \"out.json\"\n    },\n    {\n      \"path\": \"output.log\"\n    },\n    {\n      \"path\": \"package.json\"\n    },\n    {\n      \"path\": \"packages/app/package.json\"\n    },\n    {\n      \"path\": \"packages/utils/package.json\"\n    },\n    {\n      \"path\": \"pnpm-workspace.yaml\"\n    },\n    {\n      \"path\": \"snap.txt\"\n    },\n    {\n      \"path\": \"steps.json\"\n    },\n    {\n      \"path\": \"vite-plus-test-utils-1.0.0.tgz\"\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pm pack && rm -rf *.tgz # should pack current workspace root\",\n    \"vp pm pack --recursive --json > out.json && tool json-sort out.json '_.name' && cat out.json && rm -rf *.tgz # should pack all packages in workspace\",\n    \"vp pm pack --filter app && rm -rf *.tgz # should pack specific package (uses --filter app pack)\",\n    \"vp pm pack --filter app --filter @vite-plus-test/utils --json > out.json && tool json-sort out.json '_.name' && cat out.json && rm -rf *.tgz # should pack multiple packages\",\n    \"vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file\",\n    \"vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination\",\n    \"vp pm pack --pack-gzip-level 9 && rm -rf *.tgz # should pack with gzip compression level\",\n    \"vp pm pack --json --out 'foo-%s-%v.tgz' && rm -rf *.tgz # should pack with json output\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4/package.json",
    "content": "{\n  \"name\": \"command-pack-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4/snap.txt",
    "content": "> vp pm pack # should pack current package\n➤ YN0000: output.log\n➤ YN0000: package.json\n➤ YN0000: snap.txt\n➤ YN0000: steps.json\n➤ YN0000: Package archive generated in <cwd>/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n\n> vp pm pack --out ./dist/package.tgz # should pack with output file\n➤ YN0000: output.log\n➤ YN0000: package.json\n➤ YN0000: snap.txt\n➤ YN0000: steps.json\n➤ YN0000: Package archive generated in <cwd>/dist/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n\n> vp pm pack --json # should pack with json output\n{\"base\":\"<cwd>\"}\n{\"location\":\"dist/package.tgz\"}\n{\"location\":\"output.log\"}\n{\"location\":\"package.json\"}\n{\"location\":\"snap.txt\"}\n{\"location\":\"steps.json\"}\n{\"output\":\"<cwd>/package.tgz\"}\n\n> vp pm pack -- --dry-run # should support pass through arguments\n➤ YN0000: dist/package.tgz\n➤ YN0000: output.log\n➤ YN0000: package.json\n➤ YN0000: snap.txt\n➤ YN0000: steps.json\n➤ YN0000: Done in <variable>ms <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp pm pack # should pack current package\",\n    \"vp pm pack --out ./dist/package.tgz # should pack with output file\",\n    \"vp pm pack --json # should pack with json output\",\n    \"vp pm pack -- --dry-run # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-pack-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt",
    "content": "> vp install -- --mode=update-lockfile # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0073: │ Skipped due to mode=update-lockfile\n➤ YN0000: └ Completed\n➤ YN0000: · Done with warnings in <variable>ms <variable>ms\n\n> vp pm pack # should pack current workspace root\n➤ YN0000: output.log\n➤ YN0000: package.json\n➤ YN0000: snap.txt\n➤ YN0000: steps.json\n➤ YN0000: Package archive generated in <cwd>/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n\n> vp pm pack --recursive # should pack all packages in workspace (uses workspaces foreach --all pack)\n➤ YN0000: output.log\n➤ YN0000: package.json\n➤ YN0000: snap.txt\n➤ YN0000: steps.json\n➤ YN0000: Package archive generated in <cwd>/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n➤ YN0000: package.json\n➤ YN0000: Package archive generated in <cwd>/packages/app/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n➤ YN0000: package.json\n➤ YN0000: Package archive generated in <cwd>/packages/utils/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n\n> vp pm pack --filter app # should pack specific package (uses workspaces foreach --all --include app pack)\n➤ YN0000: package.json\n➤ YN0000: Package archive generated in <cwd>/packages/app/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n\n> vp pm pack --filter app --filter @vite-plus-test/utils # should pack multiple packages\n➤ YN0000: package.json\n➤ YN0000: Package archive generated in <cwd>/packages/app/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n➤ YN0000: package.json\n➤ YN0000: Package archive generated in <cwd>/packages/utils/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n\n> vp pm pack --out ./dist/package.tgz # should pack with output file\n➤ YN0000: output.log\n➤ YN0000: package.json\n➤ YN0000: snap.txt\n➤ YN0000: steps.json\n➤ YN0000: Package archive generated in <cwd>/dist/package.tgz\n➤ YN0000: Done in <variable>ms <variable>ms\n\n> vp pm pack --json # should pack with json output\n{\"base\":\"<cwd>\"}\n{\"location\":\"dist/package.tgz\"}\n{\"location\":\"output.log\"}\n{\"location\":\"package.json\"}\n{\"location\":\"snap.txt\"}\n{\"location\":\"steps.json\"}\n{\"output\":\"<cwd>/package.tgz\"}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install -- --mode=update-lockfile # should install packages first\",\n    \"vp pm pack # should pack current workspace root\",\n    \"vp pm pack --recursive # should pack all packages in workspace (uses workspaces foreach --all pack)\",\n    \"vp pm pack --filter app # should pack specific package (uses workspaces foreach --all --include app pack)\",\n    \"vp pm pack --filter app --filter @vite-plus-test/utils # should pack multiple packages\",\n    \"vp pm pack --out ./dist/package.tgz # should pack with output file\",\n    \"vp pm pack --json # should pack with json output\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pm-no-package-json/snap.txt",
    "content": "[1]> vp pm ls # should show friendly error\nNo package.json found.\n\n[1]> vp pm prune # should show friendly error\nNo package.json found.\n\n[1]> vp outdated # should show friendly error\nNo package.json found.\n\n[1]> vp why lodash # should show friendly error\nNo package.json found.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-pm-no-package-json/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm ls # should show friendly error\",\n    \"vp pm prune # should show friendly error\",\n    \"vp outdated # should show friendly error\",\n    \"vp why lodash # should show friendly error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-npm10/package.json",
    "content": "{\n  \"name\": \"command-prune-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-npm10/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 3 packages in <variable>ms\n\n> vp pm prune --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm prune [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nRemove unnecessary packages\n\nArguments:\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  --prod         Remove devDependencies\n  --no-optional  Remove optional dependencies\n  -h, --help     Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm prune # should prune extraneous dependencies\n\nup to date in <variable>ms\n\n> vp pm prune --prod # should prune dev dependencies (uses --omit=dev)\n\nup to date in <variable>ms\n\n> vp pm prune --no-optional # should prune optional dependencies (uses --omit=optional)\n\nadded 1 package in <variable>ms\n\n> vp pm prune --prod --no-optional # should prune both dev and optional dependencies\n\nup to date in <variable>ms\n\n> vp pm prune -- --loglevel=warn # should support pass through arguments\n\nadded 2 packages in <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-npm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm prune --help # should show help\",\n    \"vp pm prune # should prune extraneous dependencies\",\n    \"vp pm prune --prod # should prune dev dependencies (uses --omit=dev)\",\n    \"vp pm prune --no-optional # should prune optional dependencies (uses --omit=optional)\",\n    \"vp pm prune --prod --no-optional # should prune both dev and optional dependencies\",\n    \"vp pm prune -- --loglevel=warn # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-prune-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-pnpm10/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver>\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver>\n\nDone in <variable>ms using pnpm v<semver>\n\n> vp pm prune --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm prune [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nRemove unnecessary packages\n\nArguments:\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  --prod         Remove devDependencies\n  --no-optional  Remove optional dependencies\n  -h, --help     Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm prune && cat package.json # should prune extraneous dependencies\nLockfile is up to date, resolution step is skipped\nAlready up to date\n\n{\n  \"name\": \"command-prune-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp pm prune --prod && cat package.json # should prune dev dependencies\nLockfile is up to date, resolution step is skipped\nPackages: -1\n-\n\ndevDependencies:\n- test-vite-plus-package <semver>\n\n{\n  \"name\": \"command-prune-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp pm prune --no-optional && cat package.json # should prune optional dependencies\nLockfile is up to date, resolution step is skipped\nPackages: -1\n-\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n- test-vite-plus-package-optional <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver>\n\n{\n  \"name\": \"command-prune-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp pm prune --prod --no-optional && cat package.json # should prune both dev and optional dependencies\nLockfile is up to date, resolution step is skipped\nPackages: -1\n-\n\noptionalDependencies: skipped\n\ndevDependencies:\n- test-vite-plus-package <semver>\n\n{\n  \"name\": \"command-prune-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp pm prune -- --loglevel=warn && cat package.json # should support pass through arguments\n{\n  \"name\": \"command-prune-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp pm prune --help # should show help\",\n    \"vp pm prune && cat package.json # should prune extraneous dependencies\",\n    \"vp pm prune --prod && cat package.json # should prune dev dependencies\",\n    \"vp pm prune --no-optional && cat package.json # should prune optional dependencies\",\n    \"vp pm prune --prod --no-optional && cat package.json # should prune both dev and optional dependencies\",\n    \"vp pm prune -- --loglevel=warn && cat package.json # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-yarn4/package.json",
    "content": "{\n  \"name\": \"command-prune-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-yarn4/snap.txt",
    "content": "> vp pm prune --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm prune [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nRemove unnecessary packages\n\nArguments:\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  --prod         Remove devDependencies\n  --no-optional  Remove optional dependencies\n  -h, --help     Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm prune # should show warning that yarn does not support prune command\nwarn: yarn does not have 'prune' command. yarn install will prune extraneous packages automatically.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-prune-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm prune --help # should show help\",\n    \"vp pm prune # should show warning that yarn does not support prune command\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-npm10/package.json",
    "content": "{\n  \"name\": \"command-publish-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-npm10/snap.txt",
    "content": "> vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses npm publish --dry-run)\n+ command-publish-npm10@<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-npm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses npm publish --dry-run)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-publish-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-pnpm10/snap.txt",
    "content": "> vp pm publish --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm publish [OPTIONS] [TARBALL|FOLDER] [-- <PASS_THROUGH_ARGS>...]\n\nPublish package to registry\n\nArguments:\n  [TARBALL|FOLDER]        Tarball or folder to publish\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  --dry-run                  Preview without publishing\n  --tag <TAG>                Publish tag\n  --access <ACCESS>          Access level (public/restricted)\n  --otp <OTP>                One-time password for authentication\n  --no-git-checks            Skip git checks\n  --publish-branch <BRANCH>  Set the branch name to publish from\n  --report-summary           Save publish summary\n  --force                    Force publish\n  --json                     Output in JSON format\n  -r, --recursive            Publish all workspace packages\n  --filter <PATTERN>         Filter packages in monorepo\n  -h, --help                 Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses pnpm publish --dry-run)\n+ command-publish-pnpm10@<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm publish --help # should show help\",\n    \"vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses pnpm publish --dry-run)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-yarn1/package.json",
    "content": "{\n  \"name\": \"command-publish-yarn1\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@1.22.22\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-yarn1/snap.txt",
    "content": "> vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses npm publish --dry-run)\n+ command-publish-yarn1@<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-yarn1/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses npm publish --dry-run)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-yarn4/package.json",
    "content": "{\n  \"name\": \"command-publish-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-yarn4/snap.txt",
    "content": "> vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses npm publish --dry-run)\n+ command-publish-yarn4@<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-publish-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm publish --dry-run -- --loglevel error # should preview publish without actually publishing (uses npm publish --dry-run)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10/package.json",
    "content": "{\n  \"name\": \"command-remove-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10/snap.txt",
    "content": "> vp remove testnpm2 -D -- --no-audit && cat package.json # should pass when remove not exists package\n\nup to date in <variable>ms\n{\n  \"name\": \"command-remove-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp add testnpm2 -- --no-audit && vp add -D test-vite-plus-install -- --no-audit && vp add -O test-vite-plus-package-optional -- --no-audit && cat package.json # should add packages to dependencies\n\nadded 1 package in <variable>ms\n\nadded 1 package in <variable>ms\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-remove-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove testnpm2 test-vite-plus-install -- --no-audit && cat package.json # should remove packages from dependencies\n\nremoved 2 packages in <variable>ms\n{\n  \"name\": \"command-remove-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\",\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove -D test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support ignore -O flag and remove package from optional dependencies\n\nremoved 1 package in <variable>ms\n{\n  \"name\": \"command-remove-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n\n[1]> vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run\nFailed to uninstall testnpm2: Configuration error: Package testnpm2 is not installed\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp remove testnpm2 -D -- --no-audit && cat package.json # should pass when remove not exists package\",\n    \"vp add testnpm2 -- --no-audit && vp add -D test-vite-plus-install -- --no-audit && vp add -O test-vite-plus-package-optional -- --no-audit && cat package.json # should add packages to dependencies\",\n    \"vp remove testnpm2 test-vite-plus-install -- --no-audit && cat package.json # should remove packages from dependencies\",\n    \"vp remove -D test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support ignore -O flag and remove package from optional dependencies\",\n    \"vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-remove-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w --filter=* -- --no-audit && vp add test-vite-plus-install -w --filter=* -- --no-audit && vp add test-vite-plus-package-optional -O --filter=* -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # prepare packages\n\nadded 3 packages in <variable>ms\n\nadded 1 package in <variable>ms\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-remove-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove testnpm2 -r -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove package from all workspaces and root\n\nremoved 1 package in <variable>ms\n{\n  \"name\": \"command-remove-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove -O test-vite-plus-package-optional -r -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove optional package from all workspaces\n\nremoved 1 package in <variable>ms\n{\n  \"name\": \"command-remove-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp remove test-vite-plus-install --filter=app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=app\n\nup to date in <variable>ms\n{\n  \"name\": \"command-remove-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp remove test-vite-plus-install --filter=* -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=*\n\nup to date in <variable>ms\n{\n  \"name\": \"command-remove-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"npm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-npm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add testnpm2 -D -w --filter=* -- --no-audit && vp add test-vite-plus-install -w --filter=* -- --no-audit && vp add test-vite-plus-package-optional -O --filter=* -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # prepare packages\",\n    \"vp remove testnpm2 -r -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove package from all workspaces and root\",\n    \"vp remove -O test-vite-plus-package-optional -r -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove optional package from all workspaces\",\n    \"vp remove test-vite-plus-install --filter=app -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=app\",\n    \"vp remove test-vite-plus-install --filter=* -- --no-audit && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=*\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-remove-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10/snap.txt",
    "content": "> vp remove --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp remove [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nRemove packages from dependencies\n\nArguments:\n  <PACKAGES>...           Packages to remove\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -D, --save-dev        Only remove from `devDependencies` (pnpm-specific)\n  -O, --save-optional   Only remove from `optionalDependencies` (pnpm-specific)\n  -P, --save-prod       Only remove from `dependencies` (pnpm-specific)\n  --filter <PATTERN>    Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root  Remove from workspace root\n  -r, --recursive       Remove recursively from all workspace packages\n  -g, --global          Remove global packages\n  --dry-run             Preview what would be removed without actually removing (only with -g)\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n[2]> vp remove # should error because no packages specified\nerror: the following required arguments were not provided:\n  <PACKAGES>...\n\nUsage: vp remove <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nFor more information, try '--help'.\n\n[1]> vp remove testnpm2 -D && cat package.json # should error when remove not exists package from dev dependencies\n ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS  Cannot remove 'testnpm2': project has no 'devDependencies'\n\n> vp add testnpm2 && vp add -D test-vite-plus-install && vp add -O test-vite-plus-package-optional && cat package.json # should add packages to dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ test-vite-plus-install <semver>\n\nDone in <variable>ms using pnpm v<semver>\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove testnpm2 test-vite-plus-install && cat package.json # should remove packages from dependencies\nPackages: -2\n--\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n- testnpm2 <semver>\n\ndevDependencies:\n- test-vite-plus-install <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove -O test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support remove package from optional dependencies and pass through arguments\n{\n  \"name\": \"command-remove-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n[1]> vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run\nFailed to uninstall testnpm2: Configuration error: Package testnpm2 is not installed\n\n[2]> vp rm --stream foo && should show tips to use pass through arguments when options are not supported\nVITE+ - The Unified Toolchain for the Web\n\nerror: Unexpected argument '--stream'\n\nUse `-- --stream` to pass the argument as a value\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp remove --help # should show help\",\n    \"vp remove # should error because no packages specified\",\n    \"vp remove testnpm2 -D && cat package.json # should error when remove not exists package from dev dependencies\",\n    \"vp add testnpm2 && vp add -D test-vite-plus-install && vp add -O test-vite-plus-package-optional && cat package.json # should add packages to dependencies\",\n    \"vp remove testnpm2 test-vite-plus-install && cat package.json # should remove packages from dependencies\",\n    \"vp remove -O test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support remove package from optional dependencies and pass through arguments\",\n    \"vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run\",\n    \"vp rm --stream foo && should show tips to use pass through arguments when options are not supported\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-remove-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D -w --filter=* && vp add test-vite-plus-install -w --filter=* && vp add test-vite-plus-package-optional -O --filter=* && cat package.json packages/app/package.json packages/utils/package.json # prepare packages\n.                                        |   +1 +<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n.                                        |   +1 +<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n.                                        |   +1 +<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove testnpm2 -r && cat package.json packages/app/package.json packages/utils/package.json # should remove package from all workspaces and root\nScope: all <variable> workspace projects\n.                                        |   -1 -\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove -O test-vite-plus-package-optional -r && cat package.json packages/app/package.json packages/utils/package.json # should remove optional package from all workspaces\nScope: all <variable> workspace projects\n.                                        |   -1 -\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp remove test-vite-plus-install --filter=app && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=app\n.                                        |  WARN  `node_modules` is present. Lockfile only installation will make it out-of-date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp remove test-vite-plus-install --filter=* && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=*\nScope: all <variable> workspace projects\n.                                        |   -1 -\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-remove-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\"\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp add testnpm2 -D -w --filter=* && vp add test-vite-plus-install -w --filter=* && vp add test-vite-plus-package-optional -O --filter=* && cat package.json packages/app/package.json packages/utils/package.json # prepare packages\",\n    \"vp remove testnpm2 -r && cat package.json packages/app/package.json packages/utils/package.json # should remove package from all workspaces and root\",\n    \"vp remove -O test-vite-plus-package-optional -r && cat package.json packages/app/package.json packages/utils/package.json # should remove optional package from all workspaces\",\n    \"vp remove test-vite-plus-install --filter=app && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=app\",\n    \"vp remove test-vite-plus-install --filter=* && cat package.json packages/app/package.json packages/utils/package.json # should remove package by filter=*\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4/package.json",
    "content": "{\n  \"name\": \"command-remove-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4/snap.txt",
    "content": "[1]> vp remove testnpm2 -D && cat package.json # should error when remove not exists package\nUsage Error: Pattern testnpm2 doesn't match any packages referenced by this workspace\n\n$ yarn remove [-A,--all] [--mode #0] ...\n\n> vp add testnpm2 && vp add -D test-vite-plus-install && vp add -O test-vite-plus-package-optional && cat package.json # should add packages to dependencies\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-install@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove testnpm2 test-vite-plus-install && cat package.json # should remove packages from dependencies\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ - test-vite-plus-install@npm:1.0.0, testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove -D test-vite-plus-package-optional && cat package.json # support ignore -O flag and remove package from optional dependencies\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ - test-vite-plus-package-optional@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n[1]> vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run\nFailed to uninstall testnpm2: Configuration error: Package testnpm2 is not installed\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp remove testnpm2 -D && cat package.json # should error when remove not exists package\",\n    \"vp add testnpm2 && vp add -D test-vite-plus-install && vp add -O test-vite-plus-package-optional && cat package.json # should add packages to dependencies\",\n    \"vp remove testnpm2 test-vite-plus-install && cat package.json # should remove packages from dependencies\",\n    \"vp remove -D test-vite-plus-package-optional && cat package.json # support ignore -O flag and remove package from optional dependencies\",\n    \"vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-remove-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4-with-workspace/packages/admin/package.json",
    "content": "{\n  \"name\": \"admin\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4-with-workspace/snap.txt",
    "content": "> vp add testnpm2 -D && vp add testnpm2 -D --filter=* --filter=@vite-plus-test/utils && vp add test-vite-plus-install --filter=* --filter=@vite-plus-test/utils && vp add test-vite-plus-package-optional -O --filter=* --filter=@vite-plus-test/utils # install and ignore output\n> cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # prepare packages\n{\n  \"name\": \"command-remove-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"admin\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove testnpm2 -r && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove package from all workspaces and root\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ - testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"admin\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\"\n  }\n}\n\n> vp remove -O test-vite-plus-package-optional -r && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove optional package from all workspaces\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ - test-vite-plus-package-optional@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"admin\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp remove test-vite-plus-install --filter=app && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove package by filter=app\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"admin\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n\n> vp add test-vite-plus-install --filter=app && vp remove test-vite-plus-install --filter=* && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove package by filter=*\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"command-remove-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"packageManager\": \"yarn@<semver>\"\n}\n{\n  \"name\": \"app\"\n}\n{\n  \"name\": \"admin\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-remove-yarn4-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    {\n      \"command\": \"vp add testnpm2 -D && vp add testnpm2 -D --filter=* --filter=@vite-plus-test/utils && vp add test-vite-plus-install --filter=* --filter=@vite-plus-test/utils && vp add test-vite-plus-package-optional -O --filter=* --filter=@vite-plus-test/utils # install and ignore output\",\n      \"ignoreOutput\": true\n    },\n    \"cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # prepare packages\",\n    \"vp remove testnpm2 -r && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove package from all workspaces and root\",\n    \"vp remove -O test-vite-plus-package-optional -r && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove optional package from all workspaces\",\n    \"vp remove test-vite-plus-install --filter=app && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove package by filter=app\",\n    \"vp add test-vite-plus-install --filter=app && vp remove test-vite-plus-install --filter=* && cat package.json packages/app/package.json packages/admin/package.json packages/utils/package.json # should remove package by filter=*\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-run-without-vite-plus/package.json",
    "content": "{\n  \"name\": \"command-run-without-vite-plus\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"hello\": \"echo hello from script\",\n    \"greet\": \"echo greet\"\n  },\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-run-without-vite-plus/snap.txt",
    "content": "> vp run hello # should fall back to pnpm run when no vite-plus dependency\nVITE+ - The Unified Toolchain for the Web\n\n\n> command-run-without-vite-plus@<semver> hello <cwd>\n> echo hello from script\n\nhello from script\n\n> vp run greet --arg1 value1 # should pass through args to pnpm run\nVITE+ - The Unified Toolchain for the Web\n\n\n> command-run-without-vite-plus@<semver> greet <cwd>\n> echo greet --arg1 value1\n\ngreet --arg1 value1\n\n[1]> vp run nonexistent # should show pnpm missing script error\nVITE+ - The Unified Toolchain for the Web\n\n ERR_PNPM_NO_SCRIPT  Missing script: nonexistent\n\nCommand \"nonexistent\" not found.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-run-without-vite-plus/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp run hello # should fall back to pnpm run when no vite-plus dependency\",\n    \"vp run greet --arg1 value1 # should pass through args to pnpm run\",\n    \"vp run nonexistent # should show pnpm missing script error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-broken-config/snap.txt",
    "content": "> printf 'export default {\\n  staged: {\\n    \"*.ts\": \"vp check --fix\",\\n  },\\n  // syntax error: missing closing brace\\n' > vite.config.ts\n[1]> vp staged # should show actual config error, not 'No staged config found'\nfailed to load config from <cwd>/vite.config.ts\nFailed to load vite.config: Build failed with 1 error:\n\n[PARSE_ERROR] Error: Unexpected token\n   ╭─[ vite.config.ts:5:42 ]\n   │\n 5 │   // syntax error: missing closing brace\n   │                                          │ \n   │                                          ╰─ \n───╯\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-broken-config/steps.json",
    "content": "{\n  \"commands\": [\n    {\n      \"command\": \"printf 'export default {\\\\n  staged: {\\\\n    \\\"*.ts\\\": \\\"vp check --fix\\\",\\\\n  },\\\\n  // syntax error: missing closing brace\\\\n' > vite.config.ts\",\n      \"ignoreOutput\": true\n    },\n    \"vp staged # should show actual config error, not 'No staged config found'\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-help/snap.txt",
    "content": "> vp staged -h\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp staged [options]\n\nRun linters on staged files using staged config from vite.config.ts.\n\nOptions:\n  --allow-empty                      Allow empty commits when tasks revert all staged changes\n  -p, --concurrent <number|boolean>  Number of tasks to run concurrently, or false for serial\n  --continue-on-error                Run all tasks to completion even if one fails\n  --cwd <path>                       Working directory to run all tasks in\n  -d, --debug                        Enable debug output\n  --diff <string>                    Override the default --staged flag of git diff\n  --diff-filter <string>             Override the default --diff-filter=ACMR flag of git diff\n  --fail-on-changes                  Fail with exit code 1 when tasks modify tracked files\n  --hide-partially-staged            Hide unstaged changes from partially staged files\n  --hide-unstaged                    Hide all unstaged changes before running tasks\n  --no-stash                         Disable the backup stash\n  -q, --quiet                        Disable console output\n  -r, --relative                     Pass filepaths relative to cwd to tasks\n  --revert                           Revert to original state in case of errors\n  -v, --verbose                      Show task output even when tasks succeed\n  -h, --help                         Show this help message\n\nDocumentation: https://viteplus.dev/guide/commit-hooks\n\n\n> vp staged --help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp staged [options]\n\nRun linters on staged files using staged config from vite.config.ts.\n\nOptions:\n  --allow-empty                      Allow empty commits when tasks revert all staged changes\n  -p, --concurrent <number|boolean>  Number of tasks to run concurrently, or false for serial\n  --continue-on-error                Run all tasks to completion even if one fails\n  --cwd <path>                       Working directory to run all tasks in\n  -d, --debug                        Enable debug output\n  --diff <string>                    Override the default --staged flag of git diff\n  --diff-filter <string>             Override the default --diff-filter=ACMR flag of git diff\n  --fail-on-changes                  Fail with exit code 1 when tasks modify tracked files\n  --hide-partially-staged            Hide unstaged changes from partially staged files\n  --hide-unstaged                    Hide all unstaged changes before running tasks\n  --no-stash                         Disable the backup stash\n  -q, --quiet                        Disable console output\n  -r, --relative                     Pass filepaths relative to cwd to tasks\n  --revert                           Revert to original state in case of errors\n  -v, --verbose                      Show task output even when tasks succeed\n  -h, --help                         Show this help message\n\nDocumentation: https://viteplus.dev/guide/commit-hooks\n\n\n> vp help staged\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp staged [options]\n\nRun linters on staged files using staged config from vite.config.ts.\n\nOptions:\n  --allow-empty                      Allow empty commits when tasks revert all staged changes\n  -p, --concurrent <number|boolean>  Number of tasks to run concurrently, or false for serial\n  --continue-on-error                Run all tasks to completion even if one fails\n  --cwd <path>                       Working directory to run all tasks in\n  -d, --debug                        Enable debug output\n  --diff <string>                    Override the default --staged flag of git diff\n  --diff-filter <string>             Override the default --diff-filter=ACMR flag of git diff\n  --fail-on-changes                  Fail with exit code 1 when tasks modify tracked files\n  --hide-partially-staged            Hide unstaged changes from partially staged files\n  --hide-unstaged                    Hide all unstaged changes before running tasks\n  --no-stash                         Disable the backup stash\n  -q, --quiet                        Disable console output\n  -r, --relative                     Pass filepaths relative to cwd to tasks\n  --revert                           Revert to original state in case of errors\n  -v, --verbose                      Show task output even when tasks succeed\n  -h, --help                         Show this help message\n\nDocumentation: https://viteplus.dev/guide/commit-hooks\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-help/steps.json",
    "content": "{\n  \"commands\": [\"vp staged -h\", \"vp staged --help\", \"vp help staged\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-no-config/snap.txt",
    "content": "[1]> vp staged # should warn about missing staged config and exit with code 1\nVITE+ - The Unified Toolchain for the Web\n\nerror: No \"staged\" config found in vite.config.ts. Please add a staged config:\n\n  // vite.config.ts\n  export default defineConfig({\n    staged: { '*': 'vp check --fix' },\n  });\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-no-config/steps.json",
    "content": "{\n  \"commands\": [\"vp staged # should warn about missing staged config and exit with code 1\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-with-config/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-eval\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-with-config/package.json",
    "content": "{\n  \"name\": \"command-staged-with-config\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-with-config/snap.txt",
    "content": "> git init\n> git add -A && git commit -m 'init'\n> echo 'export const foo = 1;' >> src/index.ts && git add src/index.ts\n> vp staged # should succeed with staged .ts files\n[STARTED] Backing up original state...\n[COMPLETED] Backed up original state in git stash (<hash>)\n[STARTED] Running tasks for staged files...\n[STARTED] Config object — 1 file\n[STARTED] *.ts — 1 file\n[STARTED] *.js — 0 files\n[SKIPPED] *.js — no files\n[STARTED] vp check --fix\n[COMPLETED] vp check --fix\n[COMPLETED] *.ts — 1 file\n[COMPLETED] Config object — 1 file\n[COMPLETED] Running tasks for staged files...\n[STARTED] Applying modifications from tasks...\n[COMPLETED] Applying modifications from tasks...\n[STARTED] Cleaning up temporary files...\n[COMPLETED] Cleaning up temporary files...\n\n> git add -A && git commit -m 'second'\n> printf 'eval(\"code\");\\n' > src/fail.js && git add src/fail.js\n[1]> vp staged > /dev/null 2>&1 # should fail when staged .js file has lint errors"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-with-config/src/index.ts",
    "content": "export const hello = 'world';\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-with-config/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\", \"darwin\"],\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    { \"command\": \"git add -A && git commit -m 'init'\", \"ignoreOutput\": true },\n    {\n      \"command\": \"echo 'export const foo = 1;' >> src/index.ts && git add src/index.ts\",\n      \"ignoreOutput\": true\n    },\n    \"vp staged # should succeed with staged .ts files\",\n    { \"command\": \"git add -A && git commit -m 'second'\", \"ignoreOutput\": true },\n    {\n      \"command\": \"printf 'eval(\\\"code\\\");\\\\n' > src/fail.js && git add src/fail.js\",\n      \"ignoreOutput\": true\n    },\n    {\n      \"command\": \"vp staged > /dev/null 2>&1 # should fail when staged .js file has lint errors\",\n      \"ignoreOutput\": true\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-staged-with-config/vite.config.ts",
    "content": "export default {\n  lint: {\n    rules: {\n      'no-eval': 'error',\n    },\n  },\n  staged: {\n    '*.ts': 'vp check --fix',\n    '*.js': 'vp lint',\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-npm10/package.json",
    "content": "{\n  \"name\": \"command-unlink-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-npm10/snap.txt",
    "content": "> mkdir -p ../unlink-test-lib-npm && echo '{\"name\": \"unlink-test-lib-npm\", \"version\": \"1.0.0\"}' > ../unlink-test-lib-npm/package.json # create test library\n> vp link ../unlink-test-lib-npm && cat package.json # link the library first\n\nadded 1 package in <variable>ms\n{\n  \"name\": \"command-unlink-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp unlink unlink-test-lib-npm && cat package.json # should unlink the package\n\nremoved 1 package in <variable>ms\n{\n  \"name\": \"command-unlink-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p ../unlink-test-lib-npm && echo '{\\\"name\\\": \\\"unlink-test-lib-npm\\\", \\\"version\\\": \\\"1.0.0\\\"}' > ../unlink-test-lib-npm/package.json # create test library\",\n    \"vp link ../unlink-test-lib-npm && cat package.json # link the library first\",\n    \"vp unlink unlink-test-lib-npm && cat package.json # should unlink the package\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-unlink-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-pnpm10/snap.txt",
    "content": "> vp unlink -h # should show help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp unlink [OPTIONS] [PACKAGE|DIR] [ARGS]...\n\nUnlink packages\n\nArguments:\n  [PACKAGE|DIR]  Package name to unlink\n  [ARGS]...      Arguments to pass to package manager\n\nOptions:\n  -r, --recursive  Unlink in every workspace package\n  -h, --help       Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> mkdir -p ../unlink-test-lib && echo '{\"name\": \"unlink-test-lib\", \"version\": \"1.0.0\"}' > ../unlink-test-lib/package.json # create test library\n> vp link ../unlink-test-lib && cat package.json # link the library first\n\ndependencies:\n+ unlink-test-lib <semver> <- ../unlink-test-lib\n\n{\n  \"name\": \"command-unlink-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"unlink-test-lib\": \"link:../unlink-test-lib\"\n  }\n}\n\n> vp unlink unlink-test-lib && cat package.json # should unlink the package\nNothing to unlink\n{\n  \"name\": \"command-unlink-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"unlink-test-lib\": \"link:../unlink-test-lib\"\n  }\n}\n\n> vp link ../unlink-test-lib # link again\nLockfile is up to date, resolution step is skipped\n\n\n> vp unlink && cat package.json # should unlink all packages\nNothing to unlink\n{\n  \"name\": \"command-unlink-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@<semver>\",\n  \"dependencies\": {\n    \"unlink-test-lib\": \"link:../unlink-test-lib\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp unlink -h # should show help message\",\n    \"mkdir -p ../unlink-test-lib && echo '{\\\"name\\\": \\\"unlink-test-lib\\\", \\\"version\\\": \\\"1.0.0\\\"}' > ../unlink-test-lib/package.json # create test library\",\n    \"vp link ../unlink-test-lib && cat package.json # link the library first\",\n    \"vp unlink unlink-test-lib && cat package.json # should unlink the package\",\n    \"vp link ../unlink-test-lib # link again\",\n    \"vp unlink && cat package.json # should unlink all packages\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-yarn4/package.json",
    "content": "{\n  \"name\": \"command-unlink-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-yarn4/snap.txt",
    "content": "> mkdir -p ../unlink-test-lib-yarn && echo '{\"name\": \"unlink-test-lib-yarn\", \"version\": \"1.0.0\"}' > ../unlink-test-lib-yarn/package.json # create test library\n> vp link ../unlink-test-lib-yarn && cat package.json # link the library first\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-unlink-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"resolutions\": {\n    \"unlink-test-lib-yarn\": \"portal:<cwd>/../unlink-test-lib-yarn\"\n  }\n}\n\n> vp unlink unlink-test-lib-yarn && cat package.json # should unlink the package\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-unlink-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp link ../unlink-test-lib-yarn && cat package.json # link again\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-unlink-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\",\n  \"resolutions\": {\n    \"unlink-test-lib-yarn\": \"portal:<cwd>/../unlink-test-lib-yarn\"\n  }\n}\n\n> vp unlink --recursive && cat package.json # should unlink all with --all flag\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-unlink-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp unlink -r && cat package.json # should work with -r short form\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-unlink-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-unlink-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p ../unlink-test-lib-yarn && echo '{\\\"name\\\": \\\"unlink-test-lib-yarn\\\", \\\"version\\\": \\\"1.0.0\\\"}' > ../unlink-test-lib-yarn/package.json # create test library\",\n    \"vp link ../unlink-test-lib-yarn && cat package.json # link the library first\",\n    \"vp unlink unlink-test-lib-yarn && cat package.json # should unlink the package\",\n    \"vp link ../unlink-test-lib-yarn && cat package.json # link again\",\n    \"vp unlink --recursive && cat package.json # should unlink all with --all flag\",\n    \"vp unlink -r && cat package.json # should work with -r short form\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10/package.json",
    "content": "{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"npm@10.9.2\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10/snap.txt",
    "content": "> vp update testnpm2 -- --no-audit && cat package.json # should update package within semver range\n\nadded 3 packages in <variable>ms\n{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp up testnpm2 --latest -- --no-audit && cat package.json # should to absolute latest version\nwarn: npm doesn't support --latest flag. Updating within semver range only.\n\nup to date in <variable>ms\n{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp update -D -- --no-audit && cat package.json # should update only dev dependencies\n\nup to date in <variable>ms\n{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp update -P --no-save -- --no-audit && cat package.json # should update only dependencies and optionalDependencies without saving\n\nup to date in <variable>ms\n{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp rm testnpm2 && vp add testnpm2@1.0.0 -O -- --no-audit && vp update --no-optional --latest -- --no-audit && cat package.json # should skip optional dependencies\n\nremoved 1 package in <variable>ms\n\nadded 1 package in <variable>ms\nwarn: npm doesn't support --latest flag. Updating within semver range only.\nnpm warn config optional Use `--omit=optional` to exclude optional dependencies, or\nnpm warn config `--include=optional` to include them.\nnpm warn config\nnpm warn config       Default value does install optional deps unless otherwise omitted.\n\nchanged 1 package in <variable>ms\n{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\",\n    \"testnpm2\": \"^1.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp update -- --no-audit && cat package.json # should update all packages but won't change the package.json\n\nadded 2 packages in <variable>ms\n{\n  \"name\": \"command-update-npm10\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\",\n    \"testnpm2\": \"^1.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp update testnpm2 -- --no-audit && cat package.json # should update package within semver range\",\n    \"vp up testnpm2 --latest -- --no-audit && cat package.json # should to absolute latest version\",\n    \"vp update -D -- --no-audit && cat package.json # should update only dev dependencies\",\n    \"vp update -P --no-save -- --no-audit && cat package.json # should update only dependencies and optionalDependencies without saving\",\n    \"vp rm testnpm2 && vp add testnpm2@1.0.0 -O -- --no-audit && vp update --no-optional --latest -- --no-audit && cat package.json # should skip optional dependencies\",\n    \"vp update -- --no-audit && cat package.json # should update all packages but won't change the package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-update-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10-with-workspace/snap.txt",
    "content": "> vp update testnpm2 -w -- --no-audit && cat package.json # should update in workspace root\n\nadded 5 packages in <variable>ms\n{\n  \"name\": \"command-update-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n\n> vp update testnpm2 --latest --filter app -- --no-audit && cat packages/app/package.json # should update in specific package\nwarn: npm doesn't support --latest flag. Updating within semver range only.\n\nup to date in <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp up -D --filter app -- --no-audit && cat packages/app/package.json # should update dev dependencies in app\nnpm warn workspaces app in filter set, but no workspace folder present\n\nup to date in <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp update --filter \"*\" -- --no-audit && cat packages/app/package.json packages/utils/package.json # should update in all packages\nnpm warn workspaces app in filter set, but no workspace folder present\nnpm warn workspaces @vite-plus-test/utils in filter set, but no workspace folder present\n\nup to date in <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  }\n}\n\n> vp update -r --no-save -- --no-audit && cat package.json packages/app/package.json # should update recursively without saving\nnpm warn workspaces app in filter set, but no workspace folder present\nnpm warn workspaces @vite-plus-test/utils in filter set, but no workspace folder present\n\nup to date in <variable>ms\n{\n  \"name\": \"command-update-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp update --workspace --filter app @vite-plus-test/utils -- --no-audit && cat packages/app/package.json # should update workspace dependency\n\nup to date in <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-npm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp update testnpm2 -w -- --no-audit && cat package.json # should update in workspace root\",\n    \"vp update testnpm2 --latest --filter app -- --no-audit && cat packages/app/package.json # should update in specific package\",\n    \"vp up -D --filter app -- --no-audit && cat packages/app/package.json # should update dev dependencies in app\",\n    \"vp update --filter \\\"*\\\" -- --no-audit && cat packages/app/package.json packages/utils/package.json # should update in all packages\",\n    \"vp update -r --no-save -- --no-audit && cat package.json packages/app/package.json # should update recursively without saving\",\n    \"vp update --workspace --filter app @vite-plus-test/utils -- --no-audit && cat packages/app/package.json # should update workspace dependency\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10/snap.txt",
    "content": "> vp update --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp update [OPTIONS] [PACKAGES]... [-- <PASS_THROUGH_ARGS>...]\n\nUpdate packages to their latest versions\n\nArguments:\n  [PACKAGES]...           Packages to update (optional - updates all if omitted)\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  -L, --latest          Update to latest version (ignore semver range)\n  -g, --global          Update global packages\n  -r, --recursive       Update recursively in all workspace packages\n  --filter <PATTERN>    Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root  Include workspace root\n  -D, --dev             Update only devDependencies\n  -P, --prod            Update only dependencies (production)\n  -i, --interactive     Interactive mode\n  --no-optional         Don't update optionalDependencies\n  --no-save             Update lockfile only, don't modify package.json\n  --workspace           Only update if package exists in workspace (pnpm-specific)\n  -h, --help            Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp update testnpm2 && cat package.json # should update package within semver range\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver>\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp up testnpm2 --latest && cat package.json # should to absolute latest version\nAlready up to date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp update -D && cat package.json # should update only dev dependencies\nAlready up to date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies: skipped\n\noptionalDependencies: skipped\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp update -P --no-save && cat package.json # should update only dependencies and optionalDependencies without saving\nAlready up to date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies: skipped\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp rm testnpm2 # should remove package from dependencies for the next test\n> vp add testnpm2@1.0.0 -O && vp update --no-optional --latest && cat package.json # should skip optional dependencies\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n+ testnpm2 <semver> (1.0.1 is available)\n\nDone in <variable>ms using pnpm v<semver>\nPackages: -2\n--\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n- test-vite-plus-package-optional <semver>\n- testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp update && vp update --recursive && cat package.json # should update all packages and change the package.json\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n+ testnpm2 <semver>\n\nDone in <variable>ms using pnpm v<semver>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"^1.0.0\",\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp update --help # should show help\",\n    \"vp update testnpm2 && cat package.json # should update package within semver range\",\n    \"vp up testnpm2 --latest && cat package.json # should to absolute latest version\",\n    \"vp update -D && cat package.json # should update only dev dependencies\",\n    \"vp update -P --no-save && cat package.json # should update only dependencies and optionalDependencies without saving\",\n    {\n      \"command\": \"vp rm testnpm2 # should remove package from dependencies for the next test\",\n      \"ignoreOutput\": true\n    },\n    \"vp add testnpm2@1.0.0 -O && vp update --no-optional --latest && cat package.json # should skip optional dependencies\",\n    \"vp update && vp update --recursive && cat package.json # should update all packages and change the package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-update-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10-with-workspace/snap.txt",
    "content": "> vp update testnpm2 --latest -w && cat package.json # should update in workspace root\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\nPackages: +<variable>\n+<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp update testnpm2 --latest --filter app && cat packages/app/package.json # should update in specific package\n.                                        |  WARN  `node_modules` is present. Lockfile only installation will make it out-of-date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n.                                        |   +2 +<repeat>\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp up -D --filter app && cat packages/app/package.json # should update dev dependencies in app\n.                                        |  WARN  `node_modules` is present. Lockfile only installation will make it out-of-date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n\n> vp update --latest --filter \"*\" && cat packages/app/package.json packages/utils/package.json # should update in all packages\nScope: all <variable> workspace projects\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp update -r --no-save && cat package.json packages/app/package.json # should update recursively without saving\nScope: all <variable> workspace projects\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"command-update-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n\n> vp update --workspace --filter app @vite-plus-test/utils && cat packages/app/package.json # should update workspace dependency\n.                                        |  WARN  `node_modules` is present. Lockfile only installation will make it out-of-date\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\nDone in <variable>ms using pnpm v<semver>\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"^1.0.0\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp update testnpm2 --latest -w && cat package.json # should update in workspace root\",\n    \"vp update testnpm2 --latest --filter app && cat packages/app/package.json # should update in specific package\",\n    \"vp up -D --filter app && cat packages/app/package.json # should update dev dependencies in app\",\n    \"vp update --latest --filter \\\"*\\\" && cat packages/app/package.json packages/utils/package.json # should update in all packages\",\n    \"vp update -r --no-save && cat package.json packages/app/package.json # should update recursively without saving\",\n    \"vp update --workspace --filter app @vite-plus-test/utils && cat packages/app/package.json # should update workspace dependency\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4/package.json",
    "content": "{\n  \"name\": \"command-update-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4/snap.txt",
    "content": "> vp update testnpm2 && cat package.json # should update package within semver range\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0, test-vite-plus-package@npm:1.0.0, testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-update-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp rm testnpm2 && vp add testnpm2@1.0.0 -D && vp update testnpm2 --latest && cat package.json # should to absolute latest version\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ - testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + testnpm2@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + testnpm2@npm:1.0.1\n➤ YN0085: │ - testnpm2@npm:1.0.0\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-update-yarn4\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp update -D && cat package.json # should update and ignore -D options\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-update-yarn4\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n\n> vp update --recursive && cat package.json # should update all packages but won't change the package.json\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-update-yarn4\",\n  \"version\": \"1.0.0\",\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"*\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp update testnpm2 && cat package.json # should update package within semver range\",\n    \"vp rm testnpm2 && vp add testnpm2@1.0.0 -D && vp update testnpm2 --latest && cat package.json # should to absolute latest version\",\n    \"vp update -D && cat package.json # should update and ignore -D options\",\n    \"vp update --recursive && cat package.json # should update all packages but won't change the package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-update-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"*\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4-with-workspace/snap.txt",
    "content": "> vp update testnpm2 && cat package.json packages/utils/package.json # should update all testnpm2 versions\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-install@npm:1.0.0, test-vite-plus-package@npm:1.0.0, testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-update-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp update testnpm2 --latest --filter app && cat packages/app/package.json # should update in specific package\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp up -D --filter app && cat packages/app/package.json # should update dev dependencies in app\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp update --filter \"*\" && cat packages/app/package.json packages/utils/package.json # should update in all packages\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  }\n}\n\n> vp update -r --no-save && cat package.json packages/app/package.json # should update recursively without saving\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\n{\n  \"name\": \"command-update-yarn4-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"packageManager\": \"yarn@<semver>\"\n}\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n\n> vp update --workspace --filter app @vite-plus-test/utils && cat packages/app/package.json # should update workspace dependency\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: · Done in <variable>ms <variable>ms\nDone in <variable>ms <variable>ms\n{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:^\",\n    \"test-vite-plus-install\": \"*\",\n    \"testnpm2\": \"^1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"*\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-update-yarn4-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp update testnpm2 && cat package.json packages/utils/package.json # should update all testnpm2 versions\",\n    \"vp update testnpm2 --latest --filter app && cat packages/app/package.json # should update in specific package\",\n    \"vp up -D --filter app && cat packages/app/package.json # should update dev dependencies in app\",\n    \"vp update --filter \\\"*\\\" && cat packages/app/package.json packages/utils/package.json # should update in all packages\",\n    \"vp update -r --no-save && cat package.json packages/app/package.json # should update recursively without saving\",\n    \"vp update --workspace --filter app @vite-plus-test/utils && cat packages/app/package.json # should update workspace dependency\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-upgrade-check/snap.txt",
    "content": "> vp upgrade --check # check for updates without installing\ninfo: checking for updates...\ninfo: found vite-plus@<semver> (current: <semver>)\nUpdate available: <semver> → <semver>\nRun `vp upgrade` to update.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-upgrade-check/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp upgrade --check # check for updates without installing\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-upgrade-rollback/snap.txt",
    "content": "[1]> vp upgrade --rollback # should fail with no previous version\nerror: Upgrade error: No previous version found. Cannot rollback.\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-upgrade-rollback/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp upgrade --rollback # should fail with no previous version\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-version-no-side-effects/package.json",
    "content": "{\n  \"name\": \"command-version-no-side-effects\",\n  \"version\": \"1.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-version-no-side-effects/snap.txt",
    "content": "> vp --version # should print version\nVITE+ - The Unified Toolchain for the Web\n\nvp v<semver>\n\nLocal vite-plus:\n  vite-plus  v<semver>\n\nTools:\n  vite             v<semver>\n  rolldown         v<semver>\n  vitest           v<semver>\n  oxfmt            v<semver>\n  oxlint           v<semver>\n  oxlint-tsgolint  v<semver>\n  tsdown           v<semver>\n\nEnvironment:\n  Package manager  Not found\n  Node.js          v<semver>\n\n> test -f .node-version && echo 'FAIL: .node-version was created' || echo 'OK: no .node-version created'\nOK: no .node-version created\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-version-no-side-effects/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp --version # should print version\",\n    \"test -f .node-version && echo 'FAIL: .node-version was created' || echo 'OK: no .node-version created'\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-version-with-env/package.json",
    "content": "{\n  \"name\": \"command-version-with-env\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \"22.18.0\"\n  },\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-version-with-env/snap.txt",
    "content": "> vp --version\nVITE+ - The Unified Toolchain for the Web\n\nvp v<semver>\n\nLocal vite-plus:\n  vite-plus  v<semver>\n\nTools:\n  vite             v<semver>\n  rolldown         v<semver>\n  vitest           v<semver>\n  oxfmt            v<semver>\n  oxlint           v<semver>\n  oxlint-tsgolint  v<semver>\n  tsdown           v<semver>\n\nEnvironment:\n  Package manager  pnpm v<semver>\n  Node.js          v<semver> (engines.node)\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-version-with-env/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp --version\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-npm10/package.json",
    "content": "{\n  \"name\": \"command-view-npm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"npm@10.9.4\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-npm10/snap.txt",
    "content": "> vp pm view testnpm2 dist.tarball # should view testnpm2 package information\nhttps://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n\n> vp pm info testnpm2 dist.tarball # should info alias to view\nhttps://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n\n> vp pm show testnpm2 dist.tarball # should show alias to view\nhttps://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-npm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm view testnpm2 dist.tarball # should view testnpm2 package information\",\n    \"vp pm info testnpm2 dist.tarball # should info alias to view\",\n    \"vp pm show testnpm2 dist.tarball # should show alias to view\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-view-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.20.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-pnpm10/snap.txt",
    "content": "> vp pm view --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp pm view [OPTIONS] <PACKAGE> [FIELD] [-- <PASS_THROUGH_ARGS>...]\n\nView package information from the registry\n\nArguments:\n  <PACKAGE>               Package name with optional version\n  [FIELD]                 Specific field to view\n  [PASS_THROUGH_ARGS]...  Additional arguments\n\nOptions:\n  --json      Output in JSON format\n  -h, --help  Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp pm view testnpm2 # should view lodash package information (uses npm view)\n\ntestnpm2@<semver> | ISC | deps: none | versions: 2\n\ndist\n.tarball: https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n.shasum: <hash>\n.integrity: sha512-<hash>\n\nmaintainers:\n- fengmk2 <fengmk2@gmail.com>\n\ndist-tags:\nlatest: <semver>\nrelease-1: <semver>\n\npublished over a year ago by fengmk2 <fengmk2@gmail.com>\n\n> vp pm view testnpm2 version # should view lodash version field (uses npm view)\n1.0.1\n\n> vp pm view testnpm2@1.0.0 # should view specific version of lodash (uses npm view)\n\ntestnpm2@<semver> | ISC | deps: none | versions: 2\n\ndist\n.tarball: https://registry.<domain>/testnpm2/-/testnpm2-1.0.0.tgz\n.shasum: <hash>\n.integrity: sha512-<hash>\n\nmaintainers:\n- fengmk2 <fengmk2@gmail.com>\n\ndist-tags:\nlatest: <semver>\nrelease-1: <semver>\n\npublished over a year ago by fengmk2 <fengmk2@gmail.com>\n\n> vp pm view testnpm2 dist.tarball # should view nested field (uses npm view)\nhttps://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n\n> vp pm view testnpm2 dependencies # should view dependencies object (uses npm view)\n> vp pm view testnpm2 dist.tarball --json # should view package.dist.tarball info in JSON format (uses npm view)\n\"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\"\n\n> vp pm view testnpm2 version --json # should view field in JSON format (uses npm view)\n\"1.0.1\"\n\n> vp pm view testnpm2 -- --loglevel=warn # should support pass through arguments (uses npm view)\n\ntestnpm2@<semver> | ISC | deps: none | versions: 2\n\ndist\n.tarball: https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n.shasum: <hash>\n.integrity: sha512-<hash>\n\nmaintainers:\n- fengmk2 <fengmk2@gmail.com>\n\ndist-tags:\nlatest: <semver>\nrelease-1: <semver>\n\npublished over a year ago by fengmk2 <fengmk2@gmail.com>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm view --help # should show help\",\n    \"vp pm view testnpm2 # should view lodash package information (uses npm view)\",\n    \"vp pm view testnpm2 version # should view lodash version field (uses npm view)\",\n    \"vp pm view testnpm2@1.0.0 # should view specific version of lodash (uses npm view)\",\n    \"vp pm view testnpm2 dist.tarball # should view nested field (uses npm view)\",\n    \"vp pm view testnpm2 dependencies # should view dependencies object (uses npm view)\",\n    \"vp pm view testnpm2 dist.tarball --json # should view package.dist.tarball info in JSON format (uses npm view)\",\n    \"vp pm view testnpm2 version --json # should view field in JSON format (uses npm view)\",\n    \"vp pm view testnpm2 -- --loglevel=warn # should support pass through arguments (uses npm view)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-yarn1/package.json",
    "content": "{\n  \"name\": \"command-view-yarn1\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@1.22.22\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-yarn1/snap.txt",
    "content": "> vp pm view testnpm2 # should view testnpm2 package information (uses npm view)\n\ntestnpm2@<semver> | ISC | deps: none | versions: 2\n\ndist\n.tarball: https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n.shasum: <hash>\n.integrity: sha512-<hash>\n\nmaintainers:\n- fengmk2 <fengmk2@gmail.com>\n\ndist-tags:\nlatest: <semver>\nrelease-1: <semver>\n\npublished over a year ago by fengmk2 <fengmk2@gmail.com>\n\n> vp pm view testnpm2 version # should view testnpm2 version field (uses npm view)\n1.0.1\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-yarn1/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm view testnpm2 # should view testnpm2 package information (uses npm view)\",\n    \"vp pm view testnpm2 version # should view testnpm2 version field (uses npm view)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-yarn4/package.json",
    "content": "{\n  \"name\": \"command-view-yarn4\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-yarn4/snap.txt",
    "content": "> vp pm view testnpm2 # should view testnpm2 package information (uses npm view)\n\ntestnpm2@<semver> | ISC | deps: none | versions: 2\n\ndist\n.tarball: https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\n.shasum: <hash>\n.integrity: sha512-<hash>\n\nmaintainers:\n- fengmk2 <fengmk2@gmail.com>\n\ndist-tags:\nlatest: <semver>\nrelease-1: <semver>\n\npublished over a year ago by fengmk2 <fengmk2@gmail.com>\n\n> vp pm view testnpm2 version # should view testnpm2 version field (uses npm view)\n1.0.1\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-view-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp pm view testnpm2 # should view testnpm2 package information (uses npm view)\",\n    \"vp pm view testnpm2 version # should view testnpm2 version field (uses npm view)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-vpx-no-package-json/snap.txt",
    "content": "> vpx -s cowsay hello # should work without package.json\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-vpx-no-package-json/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vpx -s cowsay hello # should work without package.json\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-vpx-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-vpx-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-vpx-pnpm10/snap.txt",
    "content": "> vpx --help # should show vpx help message\nExecute a command from a local or remote npm package\n\nUsage: vpx [OPTIONS] <pkg[@version]> [args...]\n\nArguments:\n  <pkg[@version]>  Package binary to execute\n  [args...]        Arguments to pass to the command\n\nOptions:\n  -p, --package <NAME>  Package(s) to install if not found locally\n  -c, --shell-mode      Execute the command within a shell environment\n  -s, --silent          Suppress all output except the command's output\n  -h, --help            Print help\n\nExamples:\n  vpx eslint .                                           # Run local eslint (or download)\n  vpx create-vue my-app                                  # Download and run create-vue\n  vpx typescript@<semver> tsc --version                     # Run specific version\n  vpx -p cowsay -c 'echo \"hi\" | cowsay'                  # Shell mode with package\n\n> vpx -s cowsay hello # should run cowsay via dlx fallback\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-vpx-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vpx --help # should show vpx help message\",\n    \"vpx -s cowsay hello # should run cowsay via dlx fallback\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10/package.json",
    "content": "{\n  \"name\": \"command-why-npm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.2\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10/snap.txt",
    "content": "> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 3 packages in <variable>ms\n\n> vp why testnpm2 # should show why package is installed (uses npm explain)\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\n> vp explain testnpm2 # should work with explain alias\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\n> vp why test-vite-plus-package # should show why dev package is installed\ntest-vite-plus-package@<semver> dev\nnode_modules/test-vite-plus-package\n  dev test-vite-plus-package@\"1.0.0\" from the root project\n\n> vp why testnpm2 --json # should support json output\n[\n  {\n    \"name\": \"testnpm2\",\n    \"version\": \"1.0.1\",\n    \"location\": \"node_modules/testnpm2\",\n    \"isWorkspace\": false,\n    \"dependents\": [\n      {\n        \"type\": \"prod\",\n        \"name\": \"testnpm2\",\n        \"spec\": \"1.0.1\",\n        \"from\": {\n          \"location\": \"<cwd>\"\n        }\n      }\n    ],\n    \"dev\": false,\n    \"optional\": false,\n    \"devOptional\": false,\n    \"peer\": false,\n    \"bundled\": false,\n    \"overridden\": false\n  }\n]\n\n> vp why testnpm2 test-vite-plus-package # should support multiple packages\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\ntest-vite-plus-package@<semver> dev\nnode_modules/test-vite-plus-package\n  dev test-vite-plus-package@\"1.0.0\" from the root project\n\n> vp why testnpm2 --long # should warn that --long not supported by npm\nwarn: --long not supported by npm\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\n> vp why testnpm2 --parseable # should warn that --parseable not supported by npm\nwarn: --parseable not supported by npm\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\n> vp why testnpm2 -P # should warn that --prod not supported by npm\nwarn: --prod/--dev not supported by npm\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\n> vp why testnpm2 --find-by customFinder # should warn that --find-by not supported by npm\nwarn: --find-by not supported by npm\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n\n> vp why testnpm2 -- --omit=dev # should support pass through arguments\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.1\" from the root project\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install # should install packages first\",\n    \"vp why testnpm2 # should show why package is installed (uses npm explain)\",\n    \"vp explain testnpm2 # should work with explain alias\",\n    \"vp why test-vite-plus-package # should show why dev package is installed\",\n    \"vp why testnpm2 --json # should support json output\",\n    \"vp why testnpm2 test-vite-plus-package # should support multiple packages\",\n    \"vp why testnpm2 --long # should warn that --long not supported by npm\",\n    \"vp why testnpm2 --parseable # should warn that --parseable not supported by npm\",\n    \"vp why testnpm2 -P # should warn that --prod not supported by npm\",\n    \"vp why testnpm2 --find-by customFinder # should warn that --find-by not supported by npm\",\n    \"vp why testnpm2 -- --omit=dev # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-why-npm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.2\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10-with-workspace/snap.txt",
    "content": "> vp install\nVITE+ - The Unified Toolchain for the Web\n\n\nadded 6 packages in <variable>ms\n\n> vp why testnpm2 --filter app # should check why in specific workspace using --workspace\ntestnpm2@<semver>\nnode_modules/testnpm2\n  testnpm2@\"1.0.0\" from @vite-plus-test/utils@undefined\n  packages/utils\n    @vite-plus-test/utils@undefined\n    node_modules/@vite-plus-test/utils\n      @vite-plus-test/utils@\"*\" from app@undefined\n      packages/app\n        app@undefined\n        node_modules/app\n          workspace packages/app from the root project\n      workspace packages/utils from the root project\n  testnpm2@\"1.0.0\" from app@undefined\n  packages/app\n    app@undefined\n    node_modules/app\n      workspace packages/app from the root project\n  testnpm2@\"1.0.0\" from the root project\n\n> vp why test-vite-plus-package --filter app # should check why dev dependencies in app workspace\ntest-vite-plus-package@<semver> dev\nnode_modules/test-vite-plus-package\n  dev test-vite-plus-package@\"1.0.0\" from app@undefined\n  packages/app\n    app@undefined\n    node_modules/app\n      workspace packages/app from the root project\n\n> vp why testnpm2 --filter app --json # should support json output with workspace filter\n[\n  {\n    \"name\": \"testnpm2\",\n    \"version\": \"1.0.0\",\n    \"location\": \"node_modules/testnpm2\",\n    \"isWorkspace\": false,\n    \"dependents\": [\n      {\n        \"type\": \"prod\",\n        \"name\": \"testnpm2\",\n        \"spec\": \"1.0.0\",\n        \"from\": {\n          \"name\": \"@vite-plus-test/utils\",\n          \"errors\": [\n            {}\n          ],\n          \"package\": {\n            \"name\": \"@vite-plus-test/utils\",\n            \"dependencies\": {\n              \"testnpm2\": \"1.0.0\"\n            }\n          },\n          \"location\": \"packages/utils\",\n          \"isWorkspace\": true,\n          \"dependents\": [],\n          \"linksIn\": [\n            {\n              \"name\": \"@vite-plus-test/utils\",\n              \"errors\": [\n                {}\n              ],\n              \"package\": {\n                \"name\": \"@vite-plus-test/utils\",\n                \"dependencies\": {\n                  \"testnpm2\": \"1.0.0\"\n                }\n              },\n              \"location\": \"node_modules/@vite-plus-test/utils\",\n              \"isWorkspace\": true,\n              \"dependents\": [\n                {\n                  \"type\": \"prod\",\n                  \"name\": \"@vite-plus-test/utils\",\n                  \"spec\": \"*\",\n                  \"from\": {\n                    \"name\": \"app\",\n                    \"errors\": [\n                      {}\n                    ],\n                    \"package\": {\n                      \"name\": \"app\",\n                      \"dependencies\": {\n                        \"@vite-plus-test/utils\": \"*\",\n                        \"test-vite-plus-install\": \"1.0.0\",\n                        \"testnpm2\": \"1.0.0\"\n                      },\n                      \"devDependencies\": {\n                        \"test-vite-plus-package\": \"1.0.0\"\n                      },\n                      \"optionalDependencies\": {\n                        \"test-vite-plus-other-optional\": \"1.0.0\"\n                      }\n                    },\n                    \"location\": \"packages/app\",\n                    \"isWorkspace\": true,\n                    \"dependents\": [],\n                    \"linksIn\": [\n                      {\n                        \"name\": \"app\",\n                        \"errors\": [\n                          {}\n                        ],\n                        \"package\": {\n                          \"dependencies\": {\n                            \"@vite-plus-test/utils\": \"*\",\n                            \"test-vite-plus-install\": \"1.0.0\",\n                            \"testnpm2\": \"1.0.0\"\n                          },\n                          \"devDependencies\": {\n                            \"test-vite-plus-package\": \"1.0.0\"\n                          },\n                          \"optionalDependencies\": {\n                            \"test-vite-plus-other-optional\": \"1.0.0\"\n                          },\n                          \"name\": \"app\"\n                        },\n                        \"location\": \"node_modules/app\",\n                        \"isWorkspace\": true,\n                        \"dependents\": [\n                          {\n                            \"type\": \"workspace\",\n                            \"name\": \"app\",\n                            \"spec\": \"file:<cwd>/packages/app\",\n                            \"from\": {\n                              \"location\": \"<cwd>\"\n                            }\n                          }\n                        ]\n                      }\n                    ]\n                  }\n                },\n                {\n                  \"type\": \"workspace\",\n                  \"name\": \"@vite-plus-test/utils\",\n                  \"spec\": \"file:<cwd>/packages/utils\",\n                  \"from\": {\n                    \"location\": \"<cwd>\"\n                  }\n                }\n              ]\n            }\n          ]\n        }\n      },\n      {\n        \"type\": \"prod\",\n        \"name\": \"testnpm2\",\n        \"spec\": \"1.0.0\",\n        \"from\": {\n          \"name\": \"app\",\n          \"errors\": [\n            {}\n          ],\n          \"package\": {\n            \"name\": \"app\",\n            \"dependencies\": {\n              \"@vite-plus-test/utils\": \"*\",\n              \"test-vite-plus-install\": \"1.0.0\",\n              \"testnpm2\": \"1.0.0\"\n            },\n            \"devDependencies\": {\n              \"test-vite-plus-package\": \"1.0.0\"\n            },\n            \"optionalDependencies\": {\n              \"test-vite-plus-other-optional\": \"1.0.0\"\n            }\n          },\n          \"location\": \"packages/app\",\n          \"isWorkspace\": true,\n          \"dependents\": [],\n          \"linksIn\": [\n            {\n              \"name\": \"app\",\n              \"errors\": [\n                {}\n              ],\n              \"package\": {\n                \"dependencies\": {\n                  \"@vite-plus-test/utils\": \"*\",\n                  \"test-vite-plus-install\": \"1.0.0\",\n                  \"testnpm2\": \"1.0.0\"\n                },\n                \"devDependencies\": {\n                  \"test-vite-plus-package\": \"1.0.0\"\n                },\n                \"optionalDependencies\": {\n                  \"test-vite-plus-other-optional\": \"1.0.0\"\n                },\n                \"name\": \"app\"\n              },\n              \"location\": \"node_modules/app\",\n              \"isWorkspace\": true,\n              \"dependents\": [\n                {\n                  \"type\": \"workspace\",\n                  \"name\": \"app\",\n                  \"spec\": \"file:<cwd>/packages/app\",\n                  \"from\": {\n                    \"location\": \"<cwd>\"\n                  }\n                }\n              ]\n            }\n          ]\n        }\n      },\n      {\n        \"type\": \"prod\",\n        \"name\": \"testnpm2\",\n        \"spec\": \"1.0.0\",\n        \"from\": {\n          \"location\": \"<cwd>\"\n        }\n      }\n    ],\n    \"dev\": false,\n    \"optional\": false,\n    \"devOptional\": false,\n    \"peer\": false,\n    \"bundled\": false,\n    \"overridden\": false\n  }\n]\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-npm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install\",\n    \"vp why testnpm2 --filter app # should check why in specific workspace using --workspace\",\n    \"vp why test-vite-plus-package --filter app # should check why dev dependencies in app workspace\",\n    \"vp why testnpm2 --filter app --json # should support json output with workspace filter\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10/package.json",
    "content": "{\n  \"name\": \"command-why-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10/snap.txt",
    "content": "> vp why --help # should show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp why [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nShow why a package is installed\n\nArguments:\n  <PACKAGES>...           Package(s) to check\n  [PASS_THROUGH_ARGS]...  Additional arguments to pass through to the package manager\n\nOptions:\n  --json                   Output in JSON format\n  --long                   Show extended information\n  --parseable              Show parseable output\n  -r, --recursive          Check recursively across all workspaces\n  --filter <PATTERN>       Filter packages in monorepo\n  -w, --workspace-root     Check in workspace root\n  -P, --prod               Only production dependencies\n  -D, --dev                Only dev dependencies\n  --depth <DEPTH>          Limit tree depth\n  --no-optional            Exclude optional dependencies\n  -g, --global             Check globally installed packages\n  --exclude-peers          Exclude peer dependencies\n  --find-by <FINDER_NAME>  Use a finder function defined in .pnpmfile.cjs\n  -h, --help               Print help\n\nDocumentation: https://viteplus.dev/guide/install\n\n\n> vp install # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver>\n\noptionalDependencies:\n+ test-vite-plus-package-optional <semver>\n\ndevDependencies:\n+ test-vite-plus-package <semver>\n\nDone in <variable>ms using pnpm v<semver>\n\n> vp why testnpm2 # should show why package is installed\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\n> vp explain testnpm2 # should work with explain alias\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\n> vp why test-vite-plus-package # should show why dev package is installed\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp why testnpm2 test-vite-plus-package # should support multiple packages\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp why testnpm2 --json # should support json output\n[\n  {\n    \"name\": \"command-why-pnpm10\",\n    \"version\": \"1.0.0\",\n    \"path\": \"<cwd>\",\n    \"private\": false,\n    \"dependencies\": {\n      \"testnpm2\": {\n        \"from\": \"testnpm2\",\n        \"version\": \"1.0.1\",\n        \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.1.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\"\n      }\n    }\n  }\n]\n\n> vp why testnpm2 --long # should support long output\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n  <cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\n\n> vp why testnpm2 --parseable # should support parseable output\n<cwd>\n<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\n\n> vp why testnpm2 -P # should support prod dependencies only\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\n> vp why test-vite-plus-package -D # should support dev dependencies only\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp why testnpm2 --depth 1 # should support depth limiting\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\n> vp why test-vite-plus-package-optional --no-optional # should exclude optional dependencies\n[1]> vp why testnpm2 --find-by customFinder # should support find-by option (pnpm-specific)\n ERR_PNPM_FINDER_NOT_FOUND  No finder with name customFinder is found\n\n> vp why testnpm2 -- --reporter=silent # should support pass through arguments\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp why --help # should show help\",\n    \"vp install # should install packages first\",\n    \"vp why testnpm2 # should show why package is installed\",\n    \"vp explain testnpm2 # should work with explain alias\",\n    \"vp why test-vite-plus-package # should show why dev package is installed\",\n    \"vp why testnpm2 test-vite-plus-package # should support multiple packages\",\n    \"vp why testnpm2 --json # should support json output\",\n    \"vp why testnpm2 --long # should support long output\",\n    \"vp why testnpm2 --parseable # should support parseable output\",\n    \"vp why testnpm2 -P # should support prod dependencies only\",\n    \"vp why test-vite-plus-package -D # should support dev dependencies only\",\n    \"vp why testnpm2 --depth 1 # should support depth limiting\",\n    \"vp why test-vite-plus-package-optional --no-optional # should exclude optional dependencies\",\n    \"vp why testnpm2 --find-by customFinder # should support find-by option (pnpm-specific)\",\n    \"vp why testnpm2 -- --reporter=silent # should support pass through arguments\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10-with-workspace/package.json",
    "content": "{\n  \"name\": \"command-why-pnpm10-with-workspace\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10-with-workspace/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10-with-workspace/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10-with-workspace/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10-with-workspace/snap.txt",
    "content": "> vp install\nVITE+ - The Unified Toolchain for the Web\n\nScope: all <variable> workspace projects\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ testnpm2 <semver> (1.0.1 is available)\n\nDone in <variable>ms using pnpm v<semver>\n\n> vp why testnpm2 -w # should check why in workspace root\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10-with-workspace@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\n> vp why testnpm2 --filter app # should check why in specific package\nLegend: production dependency, optional only, dev only\n\napp <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\n└── testnpm2 <semver>\ntestnpm2 <semver>\n\n> vp why test-vite-plus-package -D --filter app # should check why dev dependencies in app\nLegend: production dependency, optional only, dev only\n\napp <cwd>/packages/app\n\ndevDependencies:\ntest-vite-plus-package <semver>\n\n> vp why testnpm2 --filter \"*\" # should check why in all packages\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10-with-workspace@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\napp <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\n└── testnpm2 <semver>\ntestnpm2 <semver>\n\n@vite-plus-test/utils <cwd>/packages/utils\n\ndependencies:\ntestnpm2 <semver>\n\n> vp why testnpm2 -r # should check why recursively\nLegend: production dependency, optional only, dev only\n\ncommand-why-pnpm10-with-workspace@<semver> <cwd>\n\ndependencies:\ntestnpm2 <semver>\n\napp <cwd>/packages/app\n\ndependencies:\n@vite-plus-test/utils link:../utils\n└── testnpm2 <semver>\ntestnpm2 <semver>\n\n@vite-plus-test/utils <cwd>/packages/utils\n\ndependencies:\ntestnpm2 <semver>\n\n> vp why testnpm2 --filter app --json # should support json output with filter\n[\n  {\n    \"name\": \"app\",\n    \"path\": \"<cwd>/packages/app\",\n    \"private\": false,\n    \"dependencies\": {\n      \"testnpm2\": {\n        \"from\": \"testnpm2\",\n        \"version\": \"1.0.0\",\n        \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.0.tgz\",\n        \"path\": \"<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\"\n      },\n      \"@vite-plus-test/utils\": {\n        \"from\": \"@vite-plus-test/utils\",\n        \"version\": \"link:../utils\",\n        \"path\": \"<cwd>/packages/utils\",\n        \"dependencies\": {\n          \"testnpm2\": {\n            \"from\": \"testnpm2\",\n            \"version\": \"1.0.0\",\n            \"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.0.tgz\",\n            \"path\": \"<cwd>/node_modules/.pnpm/testnpm2@<semver>/node_modules/testnpm2\"\n          }\n        }\n      }\n    }\n  }\n]\n\n> vp why test-vite-plus-install --filter app --depth 1 # should support depth limiting with filter\nLegend: production dependency, optional only, dev only\n\napp <cwd>/packages/app\n\ndependencies:\ntest-vite-plus-install <semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-pnpm10-with-workspace/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install\",\n    \"vp why testnpm2 -w # should check why in workspace root\",\n    \"vp why testnpm2 --filter app # should check why in specific package\",\n    \"vp why test-vite-plus-package -D --filter app # should check why dev dependencies in app\",\n    \"vp why testnpm2 --filter \\\"*\\\" # should check why in all packages\",\n    \"vp why testnpm2 -r # should check why recursively\",\n    \"vp why testnpm2 --filter app --json # should support json output with filter\",\n    \"vp why test-vite-plus-install --filter app --depth 1 # should support depth limiting with filter\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-yarn4/package.json",
    "content": "{\n  \"name\": \"command-why-yarn4\",\n  \"version\": \"1.0.0\",\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.1\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-package-optional\": \"1.0.0\"\n  },\n  \"packageManager\": \"yarn@4.10.3\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-yarn4/snap.txt",
    "content": "> vp install -- --mode=update-lockfile # should install packages first\nVITE+ - The Unified Toolchain for the Web\n\n➤ YN0000: · Yarn <semver>\n➤ YN0000: ┌ Resolution step\n➤ YN0085: │ + test-vite-plus-package-optional@npm:1.0.0, test-vite-plus-package@npm:1.0.0, testnpm2@npm:1.0.1\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0073: │ Skipped due to mode=update-lockfile\n➤ YN0000: └ Completed\n➤ YN0000: · Done with warnings in <variable>ms <variable>ms\n\n> vp why testnpm2 # should show why package is installed\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp explain testnpm2 # should work with explain alias\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why test-vite-plus-package # should show why dev package is installed\n└─ command-why-yarn4@workspace:.\n   └─ test-vite-plus-package@npm:1.0.0 (via npm:1.0.0)\n\n> vp why testnpm2 -r # should support recursive in yarn@2+\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 test-vite-plus-package # should warn about multiple packages and use first\nwarn: yarn only supports checking one package at a time, using first package\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 --json # should warn that --json not supported by yarn\nwarn: --json not supported by yarn\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 --long # should warn that --long not supported by yarn\nwarn: --long not supported by yarn\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 --parseable # should warn that --parseable not supported by yarn\nwarn: --parseable not supported by yarn\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 -P # should warn that --prod not supported by yarn\nwarn: --prod/--dev not supported by yarn\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 --find-by customFinder # should warn that --find-by not supported by yarn\nwarn: --find-by not supported by yarn\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n\n> vp why testnpm2 --exclude-peers # should exclude peers by removing --peers flag\n└─ command-why-yarn4@workspace:.\n   └─ testnpm2@npm:1.0.1 (via npm:1.0.1)\n"
  },
  {
    "path": "packages/cli/snap-tests-global/command-why-yarn4/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install -- --mode=update-lockfile # should install packages first\",\n    \"vp why testnpm2 # should show why package is installed\",\n    \"vp explain testnpm2 # should work with explain alias\",\n    \"vp why test-vite-plus-package # should show why dev package is installed\",\n    \"vp why testnpm2 -r # should support recursive in yarn@2+\",\n    \"vp why testnpm2 test-vite-plus-package # should warn about multiple packages and use first\",\n    \"vp why testnpm2 --json # should warn that --json not supported by yarn\",\n    \"vp why testnpm2 --long # should warn that --long not supported by yarn\",\n    \"vp why testnpm2 --parseable # should warn that --parseable not supported by yarn\",\n    \"vp why testnpm2 -P # should warn that --prod not supported by yarn\",\n    \"vp why testnpm2 --find-by customFinder # should warn that --find-by not supported by yarn\",\n    \"vp why testnpm2 --exclude-peers # should exclude peers by removing --peers flag\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-monorepo-subdir/apps/website/package.json",
    "content": "{\n  \"name\": \"website\",\n  \"version\": \"0.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-monorepo-subdir/package.json",
    "content": "{\n  \"name\": \"test-monorepo\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@10.12.1\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-monorepo-subdir/pnpm-workspace.yaml",
    "content": "packages:\n  - apps/*\n  - packages/*\n  - tools/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-monorepo-subdir/scripts/helper/package.json",
    "content": "{\n  \"name\": \"helper\",\n  \"version\": \"0.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-monorepo-subdir/snap.txt",
    "content": "> cd apps/website && vp create --no-interactive vite:generator # from workspace subdir\n> test -f tools/vite-plus-generator/package.json && echo 'Created at tools/vite-plus-generator' || echo 'NOT at tools/'\nCreated at tools/vite-plus-generator\n\n> test ! -f apps/website/tools/vite-plus-generator/package.json && echo 'Not in apps/website/' || echo 'BUG: in apps/website/'\nNot in apps/website/\n\n> cd apps && vp create --no-interactive vite:application # from workspace parent dir\n> test -f apps/vite-plus-application/package.json && echo 'Created at apps/vite-plus-application' || echo 'NOT at apps/'\nCreated at apps/vite-plus-application\n\n> cd scripts/helper && vp create --no-interactive vite:library # from non-workspace dir\n> test -f packages/vite-plus-library/package.json && echo 'Created at packages/vite-plus-library' || echo 'NOT at packages/'\nCreated at packages/vite-plus-library\n\n> test ! -f scripts/helper/packages/vite-plus-library/package.json && echo 'Not in scripts/helper/' || echo 'BUG: in scripts/helper/'\nNot in scripts/helper/\n\n> cd scripts/helper && vp create --no-interactive vite:application --directory apps/custom-app # --directory from non-workspace dir\n> test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'\nCreated at apps/custom-app with --directory\n\n> mkdir -p apps/dot-test && cd apps/dot-test && vp create --no-interactive vite:application --directory . # --directory . from monorepo subdir\n> test -f apps/dot-test/package.json && echo 'Created at apps/dot-test with --directory .' || echo 'NOT at apps/dot-test'\nCreated at apps/dot-test with --directory .\n\n> vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . from monorepo root should fail\nCannot scaffold into the monorepo root directory. Use --directory to specify a target directory\n\n\n> mkdir -p apps/website/src && cd apps/website/src && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . inside existing package should fail\nCannot scaffold inside existing package \"website\" (apps/website). Use --directory to specify a different location\n\n\n> cd apps/website && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . at existing package root should fail\nCannot scaffold inside existing package \"website\" (apps/website). Use --directory to specify a different location\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-monorepo-subdir/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    {\n      \"command\": \"cd apps/website && vp create --no-interactive vite:generator # from workspace subdir\",\n      \"ignoreOutput\": true\n    },\n    \"test -f tools/vite-plus-generator/package.json && echo 'Created at tools/vite-plus-generator' || echo 'NOT at tools/'\",\n    \"test ! -f apps/website/tools/vite-plus-generator/package.json && echo 'Not in apps/website/' || echo 'BUG: in apps/website/'\",\n\n    {\n      \"command\": \"cd apps && vp create --no-interactive vite:application # from workspace parent dir\",\n      \"ignoreOutput\": true\n    },\n    \"test -f apps/vite-plus-application/package.json && echo 'Created at apps/vite-plus-application' || echo 'NOT at apps/'\",\n\n    {\n      \"command\": \"cd scripts/helper && vp create --no-interactive vite:library # from non-workspace dir\",\n      \"ignoreOutput\": true\n    },\n    \"test -f packages/vite-plus-library/package.json && echo 'Created at packages/vite-plus-library' || echo 'NOT at packages/'\",\n    \"test ! -f scripts/helper/packages/vite-plus-library/package.json && echo 'Not in scripts/helper/' || echo 'BUG: in scripts/helper/'\",\n\n    {\n      \"command\": \"cd scripts/helper && vp create --no-interactive vite:application --directory apps/custom-app # --directory from non-workspace dir\",\n      \"ignoreOutput\": true\n    },\n    \"test -f apps/custom-app/package.json && echo 'Created at apps/custom-app with --directory' || echo 'NOT at apps/custom-app'\",\n\n    {\n      \"command\": \"mkdir -p apps/dot-test && cd apps/dot-test && vp create --no-interactive vite:application --directory . # --directory . from monorepo subdir\",\n      \"ignoreOutput\": true\n    },\n    \"test -f apps/dot-test/package.json && echo 'Created at apps/dot-test with --directory .' || echo 'NOT at apps/dot-test'\",\n\n    \"vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . from monorepo root should fail\",\n\n    \"mkdir -p apps/website/src && cd apps/website/src && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . inside existing package should fail\",\n\n    \"cd apps/website && vp create --no-interactive vite:application --directory . 2>&1 || true # --directory . at existing package root should fail\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-nonworkspace-subdir/package.json",
    "content": "{\n  \"name\": \"parent-project\",\n  \"version\": \"0.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-nonworkspace-subdir/scripts/.keep",
    "content": ""
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-nonworkspace-subdir/snap.txt",
    "content": "> cd scripts && vp create --no-interactive vite:application # from non-monorepo subdir\n> test -f scripts/vite-plus-application/package.json && echo 'Created at scripts/vite-plus-application' || echo 'NOT at scripts/'\nCreated at scripts/vite-plus-application\n\n> test ! -f vite-plus-application/package.json && echo 'Not at parent root' || echo 'BUG: created at parent root'\nNot at parent root\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-from-nonworkspace-subdir/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    {\n      \"command\": \"cd scripts && vp create --no-interactive vite:application # from non-monorepo subdir\",\n      \"ignoreOutput\": true\n    },\n    \"test -f scripts/vite-plus-application/package.json && echo 'Created at scripts/vite-plus-application' || echo 'NOT at scripts/'\",\n    \"test ! -f vite-plus-application/package.json && echo 'Not at parent root' || echo 'BUG: created at parent root'\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-generator-outside-monorepo/package.json",
    "content": "{\n  \"name\": \"standalone-project\",\n  \"version\": \"0.0.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-generator-outside-monorepo/snap.txt",
    "content": "[1]> vp create vite:generator --no-interactive\n\nThe vite:generator template requires a monorepo workspace.\nRun this command inside a Vite+ monorepo, or create one first with `vp create vite:monorepo`\nCannot create a generator outside a monorepo\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-generator-outside-monorepo/steps.json",
    "content": "{\n  \"commands\": [\"vp create vite:generator --no-interactive\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-missing-typecheck/snap.txt",
    "content": "> vp create vite:application --no-interactive # create standalone app\n> cat vite-plus-application/vite.config.ts # check standalone vite.config.ts has typeAware and typeCheck\nimport { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\",\n  },\n  lint: { options: { typeAware: true, typeCheck: true } },\n});\n\n> vp create vite:monorepo --no-interactive # create monorepo\n> cat vite-plus-monorepo/vite.config.ts # check monorepo root vite.config.ts has typeAware and typeCheck\nimport { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\",\n  },\n  lint: { options: { typeAware: true, typeCheck: true } },\n});\n\n> cat vite-plus-monorepo/apps/website/vite.config.ts 2>&1 || true # sub-app should NOT have typeAware/typeCheck\ncat: vite-plus-monorepo/apps/website/vite.config.ts: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/create-missing-typecheck/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"darwin\", \"win32\"],\n  \"commands\": [\n    {\n      \"command\": \"vp create vite:application --no-interactive # create standalone app\",\n      \"ignoreOutput\": true\n    },\n    \"cat vite-plus-application/vite.config.ts # check standalone vite.config.ts has typeAware and typeCheck\",\n    {\n      \"command\": \"vp create vite:monorepo --no-interactive # create monorepo\",\n      \"ignoreOutput\": true\n    },\n    \"cat vite-plus-monorepo/vite.config.ts # check monorepo root vite.config.ts has typeAware and typeCheck\",\n    \"cat vite-plus-monorepo/apps/website/vite.config.ts 2>&1 || true # sub-app should NOT have typeAware/typeCheck\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json",
    "content": "{\n  \"name\": \"delegate-respects-default-node-version\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"check-node\": \"node -e \\\"console.log(process.version)\\\"\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt",
    "content": "> vp env default 22.12.0 # Set global default to 22.12.0\nVITE+ - The Unified Toolchain for the Web\n\n✓ Default Node.js version set to <semver>\n\n> vp run check-node # Should also use 22.12.0\nVITE+ - The Unified Toolchain for the Web\n\n\n> delegate-respects-default-node-version@<semver> check-node <cwd>\n> node -e \"console.log(process.version)\"\n\nv<semver>\n\n> vp exec node -e \"console.log(process.version)\" # Should also use 22.12.0\nVITE+ - The Unified Toolchain for the Web\n\nv<semver>\n\n> vp env which node # Should show 22.12.0 from 'default' source\nVITE+ - The Unified Toolchain for the Web\n\n<vite-plus-home>/js_runtime/node/<semver>/bin/node\n  Version:    <semver>\n  Source:     <vite-plus-home>/config.json\n"
  },
  {
    "path": "packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json",
    "content": "{\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env default 22.12.0 # Set global default to 22.12.0\",\n    \"vp run check-node # Should also use 22.12.0\",\n    \"vp exec node -e \\\"console.log(process.version)\\\" # Should also use 22.12.0\",\n    \"vp env which node # Should show 22.12.0 from 'default' source\"\n  ],\n  \"after\": [\"vp env default lts # Restore default to LTS\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/dev-engines-runtime-pnpm10/package.json",
    "content": "{\n  \"name\": \"dev-engines-runtime-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"22.11.0\"\n    }\n  },\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/dev-engines-runtime-pnpm10/snap.txt",
    "content": "> vp dlx -s print-current-version # should print Node.js version 22.11.0 from devEngines.runtime\nv22.11.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/dev-engines-runtime-pnpm10/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp dlx -s print-current-version # should print Node.js version 22.11.0 from devEngines.runtime\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/.node-version",
    "content": "22.22.0"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/env-binary-conflict-pkg-a/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('Hello from pkg-a!');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/env-binary-conflict-pkg-a/package.json",
    "content": "{\n  \"name\": \"env-binary-conflict-pkg-a\",\n  \"version\": \"1.0.0\",\n  \"description\": \"Test package A that provides 'env-binary-conflict-cli' binary\",\n  \"bin\": {\n    \"env-binary-conflict-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/env-binary-conflict-pkg-b/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('Hello from pkg-b!');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/env-binary-conflict-pkg-b/package.json",
    "content": "{\n  \"name\": \"env-binary-conflict-pkg-b\",\n  \"version\": \"2.0.0\",\n  \"description\": \"Test package B that also provides 'env-binary-conflict-cli' binary\",\n  \"bin\": {\n    \"env-binary-conflict-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/snap.txt",
    "content": "> vp install -g ./env-binary-conflict-pkg-a # Install pkg-a which provides env-binary-conflict-cli binary\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./env-binary-conflict-pkg-a globally...\nInstalled ./env-binary-conflict-pkg-a v<semver>\nBinaries: env-binary-conflict-cli\n\n> cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should point to pkg-a\n{\n  \"name\": \"env-binary-conflict-cli\",\n  \"package\": \"./env-binary-conflict-pkg-a\",\n  \"version\": \"1.0.0\",\n  \"nodeVersion\": \"22.22.0\",\n  \"source\": \"vp\"\n}\n[1]> vp install -g ./env-binary-conflict-pkg-b # Try to install pkg-b without force - should fail\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./env-binary-conflict-pkg-b globally...\nFailed to install ./env-binary-conflict-pkg-b: Executable 'env-binary-conflict-cli' is already installed by ./env-binary-conflict-pkg-a\n\nPlease remove ./env-binary-conflict-pkg-a before installing ./env-binary-conflict-pkg-b, or use --force to auto-replace\n\n> cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should still point to pkg-a\n{\n  \"name\": \"env-binary-conflict-cli\",\n  \"package\": \"./env-binary-conflict-pkg-a\",\n  \"version\": \"1.0.0\",\n  \"nodeVersion\": \"22.22.0\",\n  \"source\": \"vp\"\n}\n> vp install -g --force ./env-binary-conflict-pkg-b # Force install pkg-b - should auto-uninstall pkg-a\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./env-binary-conflict-pkg-b globally...\nUninstalling ./env-binary-conflict-pkg-a (conflicts with ./env-binary-conflict-pkg-b)...\nUninstalled ./env-binary-conflict-pkg-a\nInstalled ./env-binary-conflict-pkg-b v<semver>\nBinaries: env-binary-conflict-cli\n\n> cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should now point to pkg-b\n{\n  \"name\": \"env-binary-conflict-cli\",\n  \"package\": \"./env-binary-conflict-pkg-b\",\n  \"version\": \"2.0.0\",\n  \"nodeVersion\": \"22.22.0\",\n  \"source\": \"vp\"\n}\n> vp remove -g env-binary-conflict-pkg-b # Cleanup\nUninstalled env-binary-conflict-pkg-b\n\n> test ! -f $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json && echo 'bin config removed' # Bin config should be deleted\nbin config removed\n"
  },
  {
    "path": "packages/cli/snap-tests-global/env-install-binary-conflict/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install -g ./env-binary-conflict-pkg-a # Install pkg-a which provides env-binary-conflict-cli binary\",\n    \"cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should point to pkg-a\",\n    \"vp install -g ./env-binary-conflict-pkg-b # Try to install pkg-b without force - should fail\",\n    \"cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should still point to pkg-a\",\n    \"vp install -g --force ./env-binary-conflict-pkg-b # Force install pkg-b - should auto-uninstall pkg-a\",\n    \"cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should now point to pkg-b\",\n    \"vp remove -g env-binary-conflict-pkg-b # Cleanup\",\n    \"test ! -f $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json && echo 'bin config removed' # Bin config should be deleted\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json",
    "content": "{\n  \"name\": \"fallback-all-invalid-to-user-default\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \"invalid\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt",
    "content": "> vp env default 22.12.0 # Set user default\nVITE+ - The Unified Toolchain for the Web\n\n✓ Default Node.js version set to <semver>\n\n> vp exec node -e \"console.log(process.version)\" # Should use default 22.12.0, not LTS\nVITE+ - The Unified Toolchain for the Web\n\nwarning: invalid version 'invalid' in engines.node, ignoring\nv<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json",
    "content": "{\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env default 22.12.0 # Set user default\",\n    \"vp exec node -e \\\"console.log(process.version)\\\" # Should use default 22.12.0, not LTS\"\n  ],\n  \"after\": [\"vp env default lts # Restore default\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json",
    "content": "{\n  \"name\": \"fallback-invalid-engines-to-dev-engines\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"22.12.0\"\n    }\n  },\n  \"engines\": {\n    \"node\": \"invalid\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt",
    "content": "> vp exec node -e \"console.log(process.version)\" # Should use devEngines.runtime 22.12.0, not LTS\nVITE+ - The Unified Toolchain for the Web\n\nwarning: invalid version 'invalid' in engines.node, ignoring\nwarning: invalid version 'invalid' in engines.node, ignoring\nv<semver>\n\n> vp env which node # Should show devEngines.runtime source\nVITE+ - The Unified Toolchain for the Web\n\nwarning: invalid version 'invalid' in engines.node, ignoring\n<vite-plus-home>/js_runtime/node/<semver>/bin/node\n  Version:    <semver>\n  Source:     <cwd>/package.json\n"
  },
  {
    "path": "packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp exec node -e \\\"console.log(process.version)\\\" # Should use devEngines.runtime 22.12.0, not LTS\",\n    \"vp env which node # Should show devEngines.runtime source\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/global-cli-fallback/package.json",
    "content": "{\n  \"name\": \"global-cli-fallback\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/global-cli-fallback/snap.txt",
    "content": "> vp build -h # should fall back to global vite-plus and show build help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp build [ROOT] [OPTIONS]\n\nBuild for production.\nOptions are forwarded to Vite.\n\nArguments:\n  [ROOT]  Project root directory (default: current directory)\n\nOptions:\n  --target <TARGET>    Transpile target\n  --outDir <DIR>       Output directory\n  --sourcemap [MODE]   Output source maps\n  --minify [MINIFIER]  Enable/disable minification\n  -w, --watch          Rebuild when files change\n  -c, --config <FILE>  Use specified config file\n  -m, --mode <MODE>    Set env mode\n  -h, --help           Print help\n\nExamples:\n  vp build\n  vp build --watch\n  vp build --sourcemap\n\nDocumentation: https://viteplus.dev/guide/build\n\n\n> vp dev -h # should fall back to global vite-plus and show dev help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp dev [ROOT] [OPTIONS]\n\nRun the development server.\nOptions are forwarded to Vite.\n\nArguments:\n  [ROOT]  Project root directory (default: current directory)\n\nOptions:\n  --host [HOST]        Specify hostname\n  --port <PORT>        Specify port\n  --open [PATH]        Open browser on startup\n  --strictPort         Exit if specified port is already in use\n  -c, --config <FILE>  Use specified config file\n  --base <PATH>        Public base path\n  -m, --mode <MODE>    Set env mode\n  -h, --help           Print help\n\nExamples:\n  vp dev\n  vp dev --open\n  vp dev --host localhost --port 5173\n\nDocumentation: https://viteplus.dev/guide/dev\n\n\n> vp test -h # should fall back to global vite-plus and show test help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp test [COMMAND] [FILTERS] [OPTIONS]\n\nRun tests.\nOptions are forwarded to Vitest.\n\nCommands:\n  run      Run tests once\n  watch    Run tests in watch mode\n  dev      Run tests in development mode\n  related  Run tests related to changed files\n  bench    Run benchmarks\n  init     Initialize Vitest config\n  list     List matching tests\n\nOptions:\n  -c, --config <PATH>              Path to config file\n  -w, --watch                      Enable watch mode\n  -t, --testNamePattern <PATTERN>  Run tests matching regexp\n  --ui                             Enable UI\n  --coverage                       Enable coverage\n  --reporter <NAME>                Specify reporter\n  -h, --help                       Print help\n\nExamples:\n  vp test\n  vp test run src/foo.test.ts\n  vp test watch --coverage\n\nDocumentation: https://viteplus.dev/guide/test\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/global-cli-fallback/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp build -h # should fall back to global vite-plus and show build help\",\n    \"vp dev -h # should fall back to global vite-plus and show dev help\",\n    \"vp test -h # should fall back to global vite-plus and show test help\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-add-git-hooks/package.json",
    "content": "{\n  \"name\": \"migration-add-git-hooks\",\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should add git hooks setup\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check package.json has prepare script and lint-staged config\n{\n  \"name\": \"migration-add-git-hooks\",\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> cat .vite-hooks/pre-commit # check pre-commit hook\nvp staged\n\n> test -d .vite-hooks/_ && echo 'hook shims exist' || echo 'no hook shims' # check vp config ran\nhook shims exist\n\n> git config --local core.hooksPath # should be set to .vite-hooks/_\n.vite-hooks/_\n\n> cat .vite-hooks/_/.gitignore # internal gitignore should exclude all files\n*\n> cat .vite-hooks/_/h # hook dispatcher script content\n#!/usr/bin/env sh\n{ [ \"$HUSKY\" = \"2\" ] || [ \"$VITE_GIT_HOOKS\" = \"2\" ]; } && set -x\nn=$(basename \"$0\")\ns=$(dirname \"$(dirname \"$0\")\")/$n\n\n[ ! -f \"$s\" ] && exit 0\n\ni=\"${XDG_CONFIG_HOME:-$HOME/.config}/vite-plus/hooks-init.sh\"\n[ ! -f \"$i\" ] && i=\"${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh\"\n[ -f \"$i\" ] && . \"$i\"\n\n{ [ \"${HUSKY-}\" = \"0\" ] || [ \"${VITE_GIT_HOOKS-}\" = \"0\" ]; } && exit 0\n\nd=\"$(dirname \"$(dirname \"$(dirname \"$0\")\")\")\"\nexport PATH=\"$d/node_modules/.bin:$PATH\"\nsh -e \"$s\" \"$@\"\nc=$?\n\n[ $c != 0 ] && echo \"VITE+ - $n script failed (code $c)\"\n[ $c = 127 ] && echo \"VITE+ - command not found in PATH=$PATH\"\nexit $c\n> cat .vite-hooks/_/pre-commit # hook shim should source the dispatcher\n#!/usr/bin/env sh\n. \"$(dirname \"$0\")/h\"\n> ls .vite-hooks/_/ | sort # list all generated hook shims\napplypatch-msg\ncommit-msg\nh\npost-applypatch\npost-checkout\npost-commit\npost-merge\npost-rewrite\npre-applypatch\npre-auto-gc\npre-commit\npre-merge-commit\npre-push\npre-rebase\nprepare-commit-msg\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-add-git-hooks/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should add git hooks setup\",\n    \"cat package.json # check package.json has prepare script and lint-staged config\",\n    \"cat .vite-hooks/pre-commit # check pre-commit hook\",\n    \"test -d .vite-hooks/_ && echo 'hook shims exist' || echo 'no hook shims' # check vp config ran\",\n    \"git config --local core.hooksPath # should be set to .vite-hooks/_\",\n    \"cat .vite-hooks/_/.gitignore # internal gitignore should exclude all files\",\n    \"cat .vite-hooks/_/h # hook dispatcher script content\",\n    \"cat .vite-hooks/_/pre-commit # hook shim should source the dispatcher\",\n    \"ls .vite-hooks/_/ | sort # list all generated hook shims\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-agent-claude/package.json",
    "content": "{\n  \"name\": \"migration-agent-claude\",\n  \"dependencies\": {\n    \"vitest\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-agent-claude/snap.txt",
    "content": "> vp migrate --agent claude --no-interactive # migration with --agent claude should write CLAUDE.md\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> cat CLAUDE.md | head -3 # verify CLAUDE.md was created\n<!--VITE PLUS START-->\n\n# Using Vite+, the Unified Toolchain for the Web\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-agent-claude/src/index.ts",
    "content": "import { describe, it, expect } from 'vitest';\n\ndescribe('example', () => {\n  it('should work', () => {\n    expect(1 + 1).toBe(2);\n  });\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-agent-claude/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --agent claude --no-interactive # migration with --agent claude should write CLAUDE.md\",\n    \"cat CLAUDE.md | head -3 # verify CLAUDE.md was created\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus/package.json",
    "content": "{\n  \"name\": \"migration-already-vite-plus\",\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus/snap.txt",
    "content": "> vp migrate --no-interactive # should detect existing vite-plus and exit\nVITE+ - The Unified Toolchain for the Web\n\nThis project is already using Vite+! Happy coding!\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus/steps.json",
    "content": "{\n  \"commands\": [\"vp migrate --no-interactive # should detect existing vite-plus and exit\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/.husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/package.json",
    "content": "{\n  \"name\": \"migration-already-vite-plus-with-husky-hookspath\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.7\",\n    \"vite\": \"^7.0.0\",\n    \"vite-plus\": \"latest\"\n  },\n  \"lint-staged\": {\n    \"*\": \"vp check --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/snap.txt",
    "content": "> git init\n> git config core.hooksPath .husky/_\n> vp migrate --no-interactive # should override husky's core.hooksPath and migrate hooks\nVITE+ - The Unified Toolchain for the Web\n\n◇ Updated .\n• Node <semver>  unknown latest\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # husky/lint-staged should be removed, prepare should be vp config\n{\n  \"name\": \"migration-already-vite-plus-with-husky-hookspath\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n\n> cat .vite-hooks/pre-commit # pre-commit hook should be rewritten\nvp staged\n\n> git config --local core.hooksPath # should be .vite-hooks/_\n.vite-hooks/_\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-hookspath/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    { \"command\": \"git config core.hooksPath .husky/_\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should override husky's core.hooksPath and migrate hooks\",\n    \"cat package.json # husky/lint-staged should be removed, prepare should be vp config\",\n    \"cat .vite-hooks/pre-commit # pre-commit hook should be rewritten\",\n    \"git config --local core.hooksPath # should be .vite-hooks/_\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/.husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-already-vite-plus-with-husky-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.7\",\n    \"vite\": \"^7.0.0\",\n    \"vite-plus\": \"latest\"\n  },\n  \"lint-staged\": {\n    \"*\": \"vp check --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should still migrate husky/lint-staged even though vite-plus exists\nVITE+ - The Unified Toolchain for the Web\n\n◇ Updated .\n• Node <semver>  unknown latest\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # husky/lint-staged should be removed, prepare should be vp config\n{\n  \"name\": \"migration-already-vite-plus-with-husky-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n\n> cat .vite-hooks/pre-commit # pre-commit hook should be rewritten\nvp staged\n\n> test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # .husky should be removed\nNo .husky directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-already-vite-plus-with-husky-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should still migrate husky/lint-staged even though vite-plus exists\",\n    \"cat package.json # husky/lint-staged should be removed, prepare should be vp config\",\n    \"cat .vite-hooks/pre-commit # pre-commit hook should be rewritten\",\n    \"test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # .husky should be removed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-auto-create-vite-config/.oxfmtrc.json",
    "content": "{\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-auto-create-vite-config/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-auto-create-vite-config/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"oxfmt\": \"1\",\n    \"oxlint\": \"1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt",
    "content": "> vp migrate --no-interactive # migration should auto create vite.config.ts and remove oxlintrc and oxfmtrc\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 4 config updates applied\n\n> cat vite.config.ts # check vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  fmt: {\n    \"printWidth\": 100,\n    \"tabWidth\": 2,\n    \"semi\": true,\n    \"singleQuote\": true,\n    \"trailingComma\": \"es5\"\n  },\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n});\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\ncat: .oxfmtrc.json: No such file or directory\n\n> cat package.json # check package.json\n{\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-auto-create-vite-config/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should auto create vite.config.ts and remove oxlintrc and oxfmtrc\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-baseurl-tsconfig/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-baseurl-tsconfig/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"oxlint\": \"1\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt",
    "content": "> vp migrate --no-interactive # migration should skip typeAware/typeCheck when tsconfig has baseUrl\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 3 config updates applied\n! Warnings:\n  - Skipped typeAware/typeCheck: tsconfig.json contains baseUrl which is not yet supported by the oxlint type checker.\n  Run `npx @andrewbranch/ts5to6 --fixBaseUrl .` to remove baseUrl from your tsconfig.\n\n> cat vite.config.ts # check vite.config.ts — should NOT have typeAware or typeCheck\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {}\n  },\n});\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> cat package.json # check package.json\n{\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-baseurl-tsconfig/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should skip typeAware/typeCheck when tsconfig has baseUrl\",\n    \"cat vite.config.ts # check vite.config.ts — should NOT have typeAware or typeCheck\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-baseurl-tsconfig/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2023\",\n    \"module\": \"NodeNext\",\n    \"baseUrl\": \".\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/.husky/pre-commit",
    "content": "npx lint-staged --diff HEAD~1 && npm test\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/package.json",
    "content": "{\n  \"name\": \"migration-chained-lint-staged-pre-commit\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should preserve chained commands after lint-staged\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare rewritten and husky/lint-staged removed\n{\n  \"name\": \"migration-chained-lint-staged-pre-commit\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.js\": \"vp lint --fix\"\n  },\n});\n\n> cat .vite-hooks/pre-commit # check npx lint-staged replaced but --diff HEAD~1 && npm test preserved\nvp staged --diff HEAD~1 && npm test\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should preserve chained commands after lint-staged\",\n    \"cat package.json # check prepare rewritten and husky/lint-staged removed\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\",\n    \"cat .vite-hooks/pre-commit # check npx lint-staged replaced but --diff HEAD~1 && npm test preserved\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-check/snap.txt",
    "content": "> vp migrate --help # show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp migrate [PATH] [OPTIONS]\n\nMigrate standalone Vite, Vitest, Oxlint, Oxfmt, and Prettier projects to unified Vite+.\n\nArguments:\n  PATH  Target directory to migrate (default: current directory)\n\nOptions:\n  --agent NAME      Write agent instructions file into the project (e.g. chatgpt, claude, opencode).\n  --no-agent        Skip writing agent instructions file\n  --editor NAME     Write editor config files into the project.\n  --no-editor       Skip writing editor config files\n  --hooks           Set up pre-commit hooks (default in non-interactive mode)\n  --no-hooks        Skip pre-commit hooks setup\n  --no-interactive  Run in non-interactive mode (skip prompts and use defaults)\n  -h, --help        Show this help message\n\nExamples:\n  # Migrate current package\n  vp migrate\n\n  # Migrate specific directory\n  vp migrate my-app\n\n  # Non-interactive mode\n  vp migrate --no-interactive\n\nMigration Prompt:\n  Give this to a coding agent when you want it to drive the migration:\n\n  Migrate this project to Vite+.\n  Vite+ replaces the current split tooling around runtime management, package\n  management, dev/build/test commands, linting, formatting, and packaging.\n  Run `vp help` and `vp help migrate` before making changes.\n  Use vp migrate --no-interactive in the workspace root.\n  Make sure the project is using Vite 8+ and Vitest 4.1+ before migrating.\n\n  After the migration:\n  - Confirm `vite` imports were rewritten to `vite-plus` where needed\n  - Confirm `vitest` imports were rewritten to `vite-plus/test` where needed\n  - Remove old `vite` and `vitest` dependencies only after those rewrites\n    are confirmed\n  - Move remaining tool-specific config into the appropriate blocks in\n    `vite.config.ts`\n\n  Command mapping:\n  - `vp run <script>` is the equivalent of `pnpm run <script>`\n  - `vp test` runs the built-in test command, while `vp run test` runs the\n    `test` script from `package.json`\n  - `vp install`, `vp add`, and `vp remove` delegate through the package\n    manager declared by `packageManager`\n  - `vp dev`, `vp build`, `vp preview`, `vp lint`, `vp fmt`, `vp check`,\n    and `vp pack` replace the corresponding standalone tools\n  - Prefer `vp check` for validation loops\n\n  Finally, verify the migration by running:\n  - vp install\n  - vp check\n  - vp test\n  - vp build\n\n  Summarize the migration at the end and report any manual follow-up still\n  required.\n\nDocumentation: https://viteplus.dev/guide/migrate\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-check/steps.json",
    "content": "{\n  \"commands\": [\"vp migrate --help # show help\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-composed-husky-custom-dir/package.json",
    "content": "{\n  \"name\": \"migration-composed-husky-custom-dir\",\n  \"scripts\": {\n    \"prepare\": \"npm run build && husky install .config/husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should preserve custom husky dir in composed prepare\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # prepare should be 'vp config --hooks-dir .config/husky && npm run build'\n{\n  \"name\": \"migration-composed-husky-custom-dir\",\n  \"scripts\": {\n    \"prepare\": \"npm run build && vp config --hooks-dir .config/husky\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .config/husky/pre-commit # pre-commit hook should be in custom dir\nvp staged\n\n> cat .config/husky/_/h # hook dispatcher should resolve repo root correctly for nested dirs\n#!/usr/bin/env sh\n{ [ \"$HUSKY\" = \"2\" ] || [ \"$VITE_GIT_HOOKS\" = \"2\" ]; } && set -x\nn=$(basename \"$0\")\ns=$(dirname \"$(dirname \"$0\")\")/$n\n\n[ ! -f \"$s\" ] && exit 0\n\ni=\"${XDG_CONFIG_HOME:-$HOME/.config}/vite-plus/hooks-init.sh\"\n[ ! -f \"$i\" ] && i=\"${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh\"\n[ -f \"$i\" ] && . \"$i\"\n\n{ [ \"${HUSKY-}\" = \"0\" ] || [ \"${VITE_GIT_HOOKS-}\" = \"0\" ]; } && exit 0\n\nd=\"$(dirname \"$(dirname \"$(dirname \"$(dirname \"$0\")\")\")\")\"\nexport PATH=\"$d/node_modules/.bin:$PATH\"\nsh -e \"$s\" \"$@\"\nc=$?\n\n[ $c != 0 ] && echo \"VITE+ - $n script failed (code $c)\"\n[ $c = 127 ] && echo \"VITE+ - command not found in PATH=$PATH\"\nexit $c"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-composed-husky-custom-dir/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should preserve custom husky dir in composed prepare\",\n    \"cat package.json # prepare should be 'vp config --hooks-dir .config/husky && npm run build'\",\n    \"cat .config/husky/pre-commit # pre-commit hook should be in custom dir\",\n    \"cat .config/husky/_/h # hook dispatcher should resolve repo root correctly for nested dirs\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-composed-husky-prepare/package.json",
    "content": "{\n  \"name\": \"migration-composed-husky-prepare\",\n  \"scripts\": {\n    \"prepare\": \"husky && npm run build\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should replace husky in composed prepare script\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare becomes 'vp config --hooks-dir .husky && npm run build' without leftover husky\n{\n  \"name\": \"migration-composed-husky-prepare\",\n  \"scripts\": {\n    \"prepare\": \"vp config && npm run build\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-composed-husky-prepare/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should replace husky in composed prepare script\",\n    \"cat package.json # check prepare becomes 'vp config --hooks-dir .husky && npm run build' without leftover husky\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-env-prefix-lint-staged/.husky/pre-commit",
    "content": "NODE_OPTIONS=--max-old-space-size=4096 npx lint-staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-env-prefix-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-env-prefix-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should replace env-prefixed lint-staged in pre-commit\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check husky/lint-staged removed, staged config in vite.config.ts\n{\n  \"name\": \"migration-env-prefix-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.js\": \"vp lint --fix\"\n  },\n});\n\n> cat .vite-hooks/pre-commit # check env prefix preserved with vp staged\nNODE_OPTIONS=--max-old-space-size=4096 vp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-env-prefix-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should replace env-prefixed lint-staged in pre-commit\",\n    \"cat package.json # check husky/lint-staged removed, staged config in vite.config.ts\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\",\n    \"cat .vite-hooks/pre-commit # check env prefix preserved with vp staged\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint/package.json",
    "content": "{\n  \"name\": \"migration-eslint\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint --fix .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect eslint and auto-migrate\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 4 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed and scripts rewritten\n{\n  \"name\": \"migration-eslint\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"lint\": \"vp lint .\",\n    \"lint:fix\": \"vp lint --fix .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  }\n}\n\n> cat eslint.config.mjs && exit 1 || true # check eslint config is removed\ncat: eslint.config.mjs: No such file or directory\n\n> cat vite.config.ts # check oxlint config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect eslint and auto-migrate\",\n    \"cat package.json # check eslint removed and scripts rewritten\",\n    \"cat eslint.config.mjs && exit 1 || true # check eslint config is removed\",\n    \"cat vite.config.ts # check oxlint config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy/.eslintrc",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy/package.json",
    "content": "{\n  \"name\": \"migration-eslint-legacy\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^8.23.1\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy/snap.txt",
    "content": "> vp migrate --no-interactive # should show legacy eslint config warning\nVITE+ - The Unified Toolchain for the Web\n\n\nLegacy ESLint configuration detected (.eslintrc). Automatic migration to Oxlint requires ESLint v9+ with flat config format (eslint.config.*). Please upgrade to ESLint v9 first: https://eslint.org/docs/latest/use/migrate-to-9.0.0\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy/steps.json",
    "content": "{\n  \"commands\": [\"vp migrate --no-interactive # should show legacy eslint config warning\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy-already-vite-plus/.eslintrc",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy-already-vite-plus/package.json",
    "content": "{\n  \"name\": \"migration-eslint-legacy-already-vite-plus\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^8.23.1\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy-already-vite-plus/snap.txt",
    "content": "> vp migrate --no-interactive # should show legacy config warning and already using Vite+\nVITE+ - The Unified Toolchain for the Web\n\n\nLegacy ESLint configuration detected (.eslintrc). Automatic migration to Oxlint requires ESLint v9+ with flat config format (eslint.config.*). Please upgrade to ESLint v9 first: https://eslint.org/docs/latest/use/migrate-to-9.0.0\nThis project is already using Vite+! Happy coding!\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-legacy-already-vite-plus/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # should show legacy config warning and already using Vite+\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-eslint-lint-staged\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.ts\": \"eslint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect eslint and auto-migrate including lint-staged\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 4 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed, scripts rewritten, lint-staged rewritten\n{\n  \"name\": \"migration-eslint-lint-staged\",\n  \"scripts\": {\n    \"lint\": \"vp lint .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  }\n}\n\n> cat vite.config.ts # check oxlint config and staged config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  staged: {\n    \"*.ts\": \"vp lint --fix\"\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect eslint and auto-migrate including lint-staged\",\n    \"cat package.json # check eslint removed, scripts rewritten, lint-staged rewritten\",\n    \"cat vite.config.ts # check oxlint config and staged config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged-mjs/eslint.config.mjs",
    "content": "export default [];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged-mjs/lint-staged.config.mjs",
    "content": "export default {\n  '*.ts': ['eslint --fix'],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged-mjs/package.json",
    "content": "{\n  \"name\": \"migration-eslint-lint-staged-mjs\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged-mjs/snap.txt",
    "content": "> vp migrate --no-interactive # migration should warn about non-JSON lint-staged config\nVITE+ - The Unified Toolchain for the Web\n\n\nMigrating ESLint config to Oxlint...\n\nESLint config migrated to .oxlintrc.json\n\nReplacing ESLint comments with Oxlint equivalents...\n\nESLint comments replaced\n\n✔ Removed eslint.config.mjs\n\nlint-staged.config.mjs — please update eslint references manually\n◇ Updated .\n• Node <semver>  unknown latest\n• 2 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat lint-staged.config.mjs # verify non-JSON lint-staged config is preserved unchanged\nexport default {\n  '*.ts': ['eslint --fix'],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lint-staged-mjs/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should warn about non-JSON lint-staged config\",\n    \"cat lint-staged.config.mjs # verify non-JSON lint-staged config is preserved unchanged\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lintstagedrc/.lintstagedrc.json",
    "content": "{\n  \"*.ts\": \"eslint --fix\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lintstagedrc/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lintstagedrc/package.json",
    "content": "{\n  \"name\": \"migration-eslint-lintstagedrc\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect eslint and auto-migrate including lintstagedrc\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 5 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed and scripts rewritten\n{\n  \"name\": \"migration-eslint-lintstagedrc\",\n  \"scripts\": {\n    \"lint\": \"vp lint .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  }\n}\n\n[1]> cat .lintstagedrc.json # check eslint rewritten to vp lint in lintstagedrc\ncat: .lintstagedrc.json: No such file or directory\n\n> cat vite.config.ts # check oxlint config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  staged: {\n    \"*.ts\": \"vp lint --fix\"\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-lintstagedrc/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect eslint and auto-migrate including lintstagedrc\",\n    \"cat package.json # check eslint removed and scripts rewritten\",\n    \"cat .lintstagedrc.json # check eslint rewritten to vp lint in lintstagedrc\",\n    \"cat vite.config.ts # check oxlint config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/package.json",
    "content": "{\n  \"name\": \"migration-eslint-monorepo\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint --fix .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/packages/utils/package.json",
    "content": "{\n  \"name\": \"@test/utils\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect eslint in monorepo and migrate all packages\nVITE+ - The Unified Toolchain for the Web\n\n\n✔ Created vite.config.ts in vite.config.ts\n\n✔ Merged .oxlintrc.json into vite.config.ts\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check root eslint removed and scripts rewritten\n{\n  \"name\": \"migration-eslint-monorepo\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"lint\": \"vp lint .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat packages/app/package.json # check app eslint removed and scripts rewritten\n{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"lint\": \"vp lint .\",\n    \"lint:fix\": \"vp lint --fix .\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  }\n}\n\n> cat packages/utils/package.json # check utils eslint removed and scripts rewritten\n{\n  \"name\": \"@test/utils\",\n  \"scripts\": {\n    \"lint\": \"vp lint .\"\n  },\n  \"devDependencies\": {}\n}\n\n> cat eslint.config.mjs && exit 1 || true # check root eslint config is removed\ncat: eslint.config.mjs: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect eslint in monorepo and migrate all packages\",\n    \"cat package.json # check root eslint removed and scripts rewritten\",\n    \"cat packages/app/package.json # check app eslint removed and scripts rewritten\",\n    \"cat packages/utils/package.json # check utils eslint removed and scripts rewritten\",\n    \"cat eslint.config.mjs && exit 1 || true # check root eslint config is removed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo-package-only/package.json",
    "content": "{\n  \"name\": \"migration-eslint-monorepo-package-only\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo-package-only/packages/app/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo-package-only/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint --fix .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo-package-only/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo-package-only/snap.txt",
    "content": "> vp migrate --no-interactive # migration should warn about package-only eslint\nVITE+ - The Unified Toolchain for the Web\n\n\nESLint detected in workspace packages but no root config found. Package-level ESLint must be migrated manually.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> cat package.json # check root package.json\n{\n  \"name\": \"migration-eslint-monorepo-package-only\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat packages/app/package.json # check app eslint preserved (not migrated)\n{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint --fix .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  }\n}\n\n> cat packages/app/eslint.config.mjs # check package eslint config preserved\nexport default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-monorepo-package-only/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should warn about package-only eslint\",\n    \"cat package.json # check root package.json\",\n    \"cat packages/app/package.json # check app eslint preserved (not migrated)\",\n    \"cat packages/app/eslint.config.mjs # check package eslint config preserved\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-npx-wrapper/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-npx-wrapper/package.json",
    "content": "{\n  \"name\": \"migration-eslint-npx-wrapper\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"npx eslint .\",\n    \"lint:fix\": \"pnpm exec eslint --fix .\",\n    \"lint:bunx\": \"bunx eslint .\",\n    \"lint:bare\": \"eslint --fix --ext .ts,.tsx .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite bare eslint but leave npx wrappers unchanged\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 4 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed, bare eslint rewritten, npx/pnpm exec/bunx wrappers unchanged\n{\n  \"name\": \"migration-eslint-npx-wrapper\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"lint\": \"npx eslint .\",\n    \"lint:fix\": \"pnpm exec eslint --fix .\",\n    \"lint:bunx\": \"bunx eslint .\",\n    \"lint:bare\": \"vp lint --fix .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  }\n}\n\n> cat eslint.config.mjs && exit 1 || true # check eslint config is removed\ncat: eslint.config.mjs: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-npx-wrapper/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite bare eslint but leave npx wrappers unchanged\",\n    \"cat package.json # check eslint removed, bare eslint rewritten, npx/pnpm exec/bunx wrappers unchanged\",\n    \"cat eslint.config.mjs && exit 1 || true # check eslint config is removed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun/package.json",
    "content": "{\n  \"name\": \"migration-eslint-rerun\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt",
    "content": "> vp migrate --no-interactive # should detect vite-plus + eslint and auto-migrate eslint\nVITE+ - The Unified Toolchain for the Web\n\n\nMigrating ESLint config to Oxlint...\n\nESLint config migrated to .oxlintrc.json\n\nReplacing ESLint comments with Oxlint equivalents...\n\nESLint comments replaced\n\n✔ Removed eslint.config.mjs\n◇ Updated .\n• Node <semver>  unknown latest\n• 2 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed from devDependencies and scripts rewritten\n{\n  \"name\": \"migration-eslint-rerun\",\n  \"scripts\": {\n    \"lint\": \"vp lint .\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat eslint.config.mjs && exit 1 || true # check eslint config is removed\ncat: eslint.config.mjs: No such file or directory\n\n> cat vite.config.ts # check oxlint config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  \n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # should detect vite-plus + eslint and auto-migrate eslint\",\n    \"cat package.json # check eslint removed from devDependencies and scripts rewritten\",\n    \"cat eslint.config.mjs && exit 1 || true # check eslint config is removed\",\n    \"cat vite.config.ts # check oxlint config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/.eslintrc",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"warn\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/package.json",
    "content": "{\n  \"name\": \"migration-eslint-rerun-dual-config\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt",
    "content": "> vp migrate --no-interactive # should detect vite-plus + eslint and auto-migrate eslint\nVITE+ - The Unified Toolchain for the Web\n\n\nMigrating ESLint config to Oxlint...\n\nESLint config migrated to .oxlintrc.json\n\nReplacing ESLint comments with Oxlint equivalents...\n\nESLint comments replaced\n\n✔ Removed eslint.config.mjs\n\n✔ Removed .eslintrc\n◇ Updated .\n• Node <semver>  unknown latest\n• 2 config updates applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed and scripts rewritten\n{\n  \"name\": \"migration-eslint-rerun-dual-config\",\n  \"scripts\": {\n    \"lint\": \"vp lint .\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat eslint.config.mjs && exit 1 || true # check flat config is removed\ncat: eslint.config.mjs: No such file or directory\n\n> cat .eslintrc && exit 1 || true # check legacy config is also removed\ncat: .eslintrc: No such file or directory\n\n> cat vite.config.ts # check oxlint config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  \n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # should detect vite-plus + eslint and auto-migrate eslint\",\n    \"cat package.json # check eslint removed and scripts rewritten\",\n    \"cat eslint.config.mjs && exit 1 || true # check flat config is removed\",\n    \"cat .eslintrc && exit 1 || true # check legacy config is also removed\",\n    \"cat vite.config.ts # check oxlint config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-mjs/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-mjs/package.json",
    "content": "{\n  \"name\": \"migration-eslint-rerun-mjs\",\n  \"scripts\": {\n    \"lint\": \"eslint .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt",
    "content": "> vp migrate --no-interactive # should detect vite-plus + eslint and auto-migrate eslint\nVITE+ - The Unified Toolchain for the Web\n\n\nMigrating ESLint config to Oxlint...\n\nESLint config migrated to .oxlintrc.json\n\nReplacing ESLint comments with Oxlint equivalents...\n\nESLint comments replaced\n\n✔ Removed eslint.config.mjs\n◇ Updated .\n• Node <semver>  unknown latest\n• 1 config update applied\n• ESLint rules migrated to Oxlint\n\n> cat package.json # check eslint removed from devDependencies and scripts rewritten\n{\n  \"name\": \"migration-eslint-rerun-mjs\",\n  \"scripts\": {\n    \"lint\": \"vp lint .\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat eslint.config.mjs && exit 1 || true # check eslint config is removed\ncat: eslint.config.mjs: No such file or directory\n\n> cat vite.config.mjs # check oxlint config merged into existing vite.config.mjs (not creating vite.config.ts)\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  \n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-mjs/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # should detect vite-plus + eslint and auto-migrate eslint\",\n    \"cat package.json # check eslint removed from devDependencies and scripts rewritten\",\n    \"cat eslint.config.mjs && exit 1 || true # check eslint config is removed\",\n    \"cat vite.config.mjs # check oxlint config merged into existing vite.config.mjs (not creating vite.config.ts)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-eslint-rerun-mjs/vite.config.mjs",
    "content": "import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky/.husky/pre-commit",
    "content": "pnpm lint-staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky/package.json",
    "content": "{\n  \"name\": \"migration-existing-husky\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should rewrite husky to vp config\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare script rewritten and husky removed from devDeps\n{\n  \"name\": \"migration-existing-husky\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .vite-hooks/pre-commit # check pre-commit hook rewritten to vp staged\nvp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should rewrite husky to vp config\",\n    \"cat package.json # check prepare script rewritten and husky removed from devDeps\",\n    \"cat .vite-hooks/pre-commit # check pre-commit hook rewritten to vp staged\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-lint-staged/.husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-existing-husky-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should rewrite husky and lint-staged\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare rewritten, lint-staged removed, both removed from devDeps\n{\n  \"name\": \"migration-existing-husky-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.js\": \"vp lint --fix\"\n  },\n});\n\n> cat .vite-hooks/pre-commit # check pre-commit hook rewritten\nvp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should rewrite husky and lint-staged\",\n    \"cat package.json # check prepare rewritten, lint-staged removed, both removed from devDeps\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\",\n    \"cat .vite-hooks/pre-commit # check pre-commit hook rewritten\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/.husky/pre-commit",
    "content": ". \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpm test\necho \"custom hook\"\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/package.json",
    "content": "{\n  \"name\": \"migration-existing-husky-v8-hooks\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should warn about husky v8 and skip hooks setup\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # husky/lint-staged should remain in devDeps, prepare should stay as husky\n{\n  \"name\": \"migration-existing-husky-v8-hooks\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .husky/pre-commit # hook file should be unchanged (still has bootstrap)\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpm test\necho \"custom hook\"\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should warn about husky v8 and skip hooks setup\",\n    \"cat package.json # husky/lint-staged should remain in devDeps, prepare should stay as husky\",\n    \"cat .husky/pre-commit # hook file should be unchanged (still has bootstrap)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/.husky/commit-msg",
    "content": ". \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx commitlint --edit $1\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/.husky/pre-commit",
    "content": ". \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/package.json",
    "content": "{\n  \"name\": \"migration-existing-husky-v8-multi-hooks\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should warn about husky v8 and skip hooks setup\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # husky/lint-staged should remain in devDeps, prepare should stay as husky\n{\n  \"name\": \"migration-existing-husky-v8-multi-hooks\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .husky/pre-commit # hook file should be unchanged (still has bootstrap)\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n\n> cat .husky/commit-msg # hook file should be unchanged (still has bootstrap)\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nnpx commitlint --edit $1\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should warn about husky v8 and skip hooks setup\",\n    \"cat package.json # husky/lint-staged should remain in devDeps, prepare should stay as husky\",\n    \"cat .husky/pre-commit # hook file should be unchanged (still has bootstrap)\",\n    \"cat .husky/commit-msg # hook file should be unchanged (still has bootstrap)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-lint-staged-config/.lintstagedrc.json",
    "content": "{\n  \"*.ts\": \"oxlint --fix\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-lint-staged-config/package.json",
    "content": "{\n  \"name\": \"migration-existing-lint-staged-config\",\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should add prepare script, remove lint-staged from devDeps\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 3 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare script added, lint-staged removed from devDeps\n{\n  \"name\": \"migration-existing-lint-staged-config\",\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n[1]> cat .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining to vite.config.ts)\ncat: .lintstagedrc.json: No such file or directory\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.ts\": \"vp lint --fix\"\n  },\n});\n\n> cat .vite-hooks/pre-commit # check pre-commit hook created\nvp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-lint-staged-config/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should add prepare script, remove lint-staged from devDeps\",\n    \"cat package.json # check prepare script added, lint-staged removed from devDeps\",\n    \"cat .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining to vite.config.ts)\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\",\n    \"cat .vite-hooks/pre-commit # check pre-commit hook created\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/.husky/pre-commit",
    "content": "pnpm exec lint-staged --concurrent false\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-existing-pnpm-exec-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should strip pnpm exec lint-staged and add vp staged\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare rewritten and husky/lint-staged removed\n{\n  \"name\": \"migration-existing-pnpm-exec-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.js\": \"vp lint --fix\"\n  },\n});\n\n> cat .vite-hooks/pre-commit # check pnpm exec lint-staged replaced with vp staged\nvp staged --concurrent false\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should strip pnpm exec lint-staged and add vp staged\",\n    \"cat package.json # check prepare rewritten and husky/lint-staged removed\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\",\n    \"cat .vite-hooks/pre-commit # check pnpm exec lint-staged replaced with vp staged\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pre-commit/package.json",
    "content": "{\n  \"name\": \"migration-existing-pre-commit\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pre-commit/snap.txt",
    "content": "> git init\n> mkdir -p .husky && printf '#!/usr/bin/env sh\\nnpm test\\nsecret-scan\\n' > .husky/pre-commit && chmod 755 .husky/pre-commit\n> cat .husky/pre-commit # check existing pre-commit hook before migration\n#!/usr/bin/env sh\nnpm test\nsecret-scan\n\n> vp migrate --no-interactive # migration should preserve existing pre-commit contents\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat .vite-hooks/pre-commit # check pre-commit hook preserves existing commands\n#!/usr/bin/env sh\nnpm test\nsecret-scan\nvp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-pre-commit/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    {\n      \"command\": \"mkdir -p .husky && printf '#!/usr/bin/env sh\\\\nnpm test\\\\nsecret-scan\\\\n' > .husky/pre-commit && chmod 755 .husky/pre-commit\",\n      \"ignoreOutput\": true\n    },\n    \"cat .husky/pre-commit # check existing pre-commit hook before migration\",\n    \"vp migrate --no-interactive # migration should preserve existing pre-commit contents\",\n    \"cat .vite-hooks/pre-commit # check pre-commit hook preserves existing commands\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-prepare-script/package.json",
    "content": "{\n  \"name\": \"migration-existing-prepare-script\",\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"prepare\": \"npm run build\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should compose vp config with existing prepare script\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare script is composed: vp config && npm run build\n{\n  \"name\": \"migration-existing-prepare-script\",\n  \"scripts\": {\n    \"build\": \"tsc\",\n    \"prepare\": \"vp config && npm run build\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .vite-hooks/pre-commit # check pre-commit hook\nvp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-existing-prepare-script/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should compose vp config with existing prepare script\",\n    \"cat package.json # check prepare script is composed: vp config && npm run build\",\n    \"cat .vite-hooks/pre-commit # check pre-commit hook\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown/package.json",
    "content": "{\n  \"name\": \"migration-from-tsdown\",\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"build:watch\": \"tsdown --watch\",\n    \"build:dts\": \"tsdown --dts\"\n  },\n  \"devDependencies\": {\n    \"tsdown\": \"^0.5.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite imports to vite-plus\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 3 config updates applied, 1 file had imports rewritten\n→ Manual follow-up:\n  - Please manually merge tsdown.config.ts into vite.config.ts, see https://viteplus.dev/guide/migrate#tsdown\n\n> cat tsdown.config.ts # check tsdown.config.ts\nimport { defineConfig } from 'vite-plus/pack';\n\nexport default defineConfig({\n  entry: 'src/index.ts',\n  outDir: 'dist',\n  format: ['esm', 'cjs'],\n  dts: true,\n});\n\n> cat vite.config.ts # check vite.config.ts\nimport tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  pack: tsdownConfig,\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-from-tsdown\",\n  \"scripts\": {\n    \"build\": \"vp pack\",\n    \"build:watch\": \"vp pack --watch\",\n    \"build:dts\": \"vp pack --dts\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp migrate --no-interactive # run migration again to check if it is idempotent\nVITE+ - The Unified Toolchain for the Web\n\nThis project is already using Vite+! Happy coding!\n\n\n> cat tsdown.config.ts # check tsdown.config.ts\nimport { defineConfig } from 'vite-plus/pack';\n\nexport default defineConfig({\n  entry: 'src/index.ts',\n  outDir: 'dist',\n  format: ['esm', 'cjs'],\n  dts: true,\n});\n\n> cat vite.config.ts # check vite.config.ts\nimport tsdownConfig from './tsdown.config.js';\n\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  pack: tsdownConfig,\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-from-tsdown\",\n  \"scripts\": {\n    \"build\": \"vp pack\",\n    \"build:watch\": \"vp pack --watch\",\n    \"build:dts\": \"vp pack --dts\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown/src/index.ts",
    "content": "export function hello(name: string): string {\n  return `Hello, ${name}!`;\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite imports to vite-plus\",\n    \"cat tsdown.config.ts # check tsdown.config.ts\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat package.json # check package.json\",\n    \"vp migrate --no-interactive # run migration again to check if it is idempotent\",\n    \"cat tsdown.config.ts # check tsdown.config.ts\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n  entry: 'src/index.ts',\n  outDir: 'dist',\n  format: ['esm', 'cjs'],\n  dts: true,\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown-json-config/package.json",
    "content": "{\n  \"name\": \"migration-from-tsdown-json-config\",\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"build:watch\": \"tsdown --watch\",\n    \"build:dts\": \"tsdown --dts\"\n  },\n  \"devDependencies\": {\n    \"tsdown\": \"^0.5.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite imports to vite-plus\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> cat tsdown.config.json && exit 1 || true # check tsdown.config.json should be removed\ncat: tsdown.config.json: No such file or directory\n\n> cat vite.config.ts # check vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  pack: {\n    \"entry\": \"src/index.ts\",\n    \"outDir\": \"dist\",\n    \"format\": [\"esm\", \"cjs\"],\n    \"dts\": true,\n    \"inputOptions\": {\n      \"cwd\": \"./src\"\n    }\n  },\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  server: {\n    port: 3000,\n  },\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-from-tsdown-json-config\",\n  \"scripts\": {\n    \"build\": \"vp pack\",\n    \"build:watch\": \"vp pack --watch\",\n    \"build:dts\": \"vp pack --dts\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> vp migrate --no-interactive # run migration again to check if it is idempotent\nVITE+ - The Unified Toolchain for the Web\n\nThis project is already using Vite+! Happy coding!\n\n\n> cat vite.config.ts # check vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  pack: {\n    \"entry\": \"src/index.ts\",\n    \"outDir\": \"dist\",\n    \"format\": [\"esm\", \"cjs\"],\n    \"dts\": true,\n    \"inputOptions\": {\n      \"cwd\": \"./src\"\n    }\n  },\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  server: {\n    port: 3000,\n  },\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-from-tsdown-json-config\",\n  \"scripts\": {\n    \"build\": \"vp pack\",\n    \"build:watch\": \"vp pack --watch\",\n    \"build:dts\": \"vp pack --dts\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown-json-config/src/index.ts",
    "content": "export function hello(name: string): string {\n  return `Hello, ${name}!`;\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown-json-config/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite imports to vite-plus\",\n    \"cat tsdown.config.json && exit 1 || true # check tsdown.config.json should be removed\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat package.json # check package.json\",\n    \"vp migrate --no-interactive # run migration again to check if it is idempotent\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown-json-config/tsdown.config.json",
    "content": "{\n  \"entry\": \"src/index.ts\",\n  \"outDir\": \"dist\",\n  \"format\": [\"esm\", \"cjs\"],\n  \"dts\": true,\n  \"inputOptions\": {\n    \"cwd\": \"./src\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-tsdown-json-config/vite.config.ts",
    "content": "import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  server: {\n    port: 3000,\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-config/package.json",
    "content": "{\n  \"name\": \"migration-from-vitest-config\",\n  \"scripts\": {\n    \"test:run\": \"vitest run\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest --watch\",\n    \"test\": \"vitest\"\n  },\n  \"devDependencies\": {\n    \"@vitest/browser\": \"^4.0.0\",\n    \"@vitest/browser-playwright\": \"^4.0.0\",\n    \"vite\": \"^7.0.0\",\n    \"vitest\": \"^4.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite imports to vite-plus\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat vitest.config.ts # check vitest.config.ts\nimport { join } from 'node:path';\n\nimport { foo } from '@foo/vite-plugin-foo';\nimport { playwright } from 'vite-plus/test/browser-playwright';\nimport { server } from 'vite-plus/test/browser-playwright/context';\nimport { preview } from 'vite-plus/test/browser-preview';\nimport { webdriverio } from 'vite-plus/test/browser-webdriverio';\nimport { userEvent } from 'vite-plus/test/browser/context';\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [foo()],\n  test: {\n    dir: join(import.meta.dirname, 'test'),\n    browser: {\n      enabled: true,\n      provider: playwright(),\n      headless: true,\n      screenshotFailures: false,\n      instances: [{ browser: 'chromium' }],\n    },\n  },\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-from-vitest-config\",\n  \"scripts\": {\n    \"test:run\": \"vp test run\",\n    \"test:ui\": \"vp test --ui\",\n    \"test:coverage\": \"vp test run --coverage\",\n    \"test:watch\": \"vp test --watch\",\n    \"test\": \"vp test\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-config/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite imports to vite-plus\",\n    \"cat vitest.config.ts # check vitest.config.ts\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-config/vitest.config.ts",
    "content": "import { join } from 'node:path';\n\nimport { foo } from '@foo/vite-plugin-foo';\nimport { playwright } from '@vitest/browser-playwright';\nimport { server } from '@vitest/browser-playwright/context';\nimport { preview } from '@vitest/browser-preview';\nimport { webdriverio } from '@vitest/browser-webdriverio';\nimport { userEvent } from '@vitest/browser/context';\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  plugins: [foo()],\n  test: {\n    dir: join(import.meta.dirname, 'test'),\n    browser: {\n      enabled: true,\n      provider: playwright(),\n      headless: true,\n      screenshotFailures: false,\n      instances: [{ browser: 'chromium' }],\n    },\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-files/package.json",
    "content": "{\n  \"name\": \"migration-from-vitest-files\",\n  \"scripts\": {\n    \"test:run\": \"vitest run\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest --watch\",\n    \"test\": \"vitest\"\n  },\n  \"devDependencies\": {\n    \"@vitest/browser\": \"^4.0.0\",\n    \"@vitest/browser-playwright\": \"^4.0.0\",\n    \"vite\": \"^7.0.0\",\n    \"vitest\": \"^4.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite imports to vite-plus\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-from-vitest-files\",\n  \"scripts\": {\n    \"test:run\": \"vp test run\",\n    \"test:ui\": \"vp test --ui\",\n    \"test:coverage\": \"vp test run --coverage\",\n    \"test:watch\": \"vp test --watch\",\n    \"test\": \"vp test\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat test/hello.ts # check test/hello.ts\nimport { server } from 'vite-plus/test/browser-playwright/context';\nimport { test, describe, expect, it } from 'vite-plus/test';\n\nconst { readFile } = server.commands;\n\ndescribe('Hello', () => {\n  it('should return the correct result', () => {\n    expect(true).toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-files/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite imports to vite-plus\",\n    \"cat package.json # check package.json\",\n    \"cat test/hello.ts # check test/hello.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-from-vitest-files/test/hello.ts",
    "content": "import { server } from '@vitest/browser-playwright/context';\nimport { test, describe, expect, it } from 'vitest';\n\nconst { readFile } = server.commands;\n\ndescribe('Hello', () => {\n  it('should return the correct result', () => {\n    expect(true).toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/package.json",
    "content": "{\n  \"name\": \"migration-hooks-skip-on-existing-hookspath\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt",
    "content": "> git init\n> git config core.hooksPath .custom-hooks\n> vp migrate --no-interactive # should skip hooks because core.hooksPath is already set\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n! Warnings:\n  - Git hooks not configured — core.hooksPath is already set to \".custom-hooks\", skipping\n\n> cat package.json # prepare should stay 'husky' and husky must remain in devDependencies\n{\n  \"name\": \"migration-hooks-skip-on-existing-hookspath\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> git config --local core.hooksPath # should still be .custom-hooks\n.custom-hooks\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    { \"command\": \"git config core.hooksPath .custom-hooks\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should skip hooks because core.hooksPath is already set\",\n    \"cat package.json # prepare should stay 'husky' and husky must remain in devDependencies\",\n    \"git config --local core.hooksPath # should still be .custom-hooks\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-env-skip/package.json",
    "content": "{\n  \"name\": \"migration-husky-env-skip\",\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-env-skip/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # with HUSKY=0, vp config should skip and warn instead of reporting success\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n! Warnings:\n  - Git hooks not configured — skip install (git hooks disabled)\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-env-skip/steps.json",
    "content": "{\n  \"env\": {\n    \"HUSKY\": \"0\"\n  },\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # with HUSKY=0, vp config should skip and warn instead of reporting success\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-or-prepare/package.json",
    "content": "{\n  \"name\": \"migration-husky-or-prepare\",\n  \"scripts\": {\n    \"prepare\": \"husky || true\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should preserve || fallback semantics\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check prepare script preserves || true fallback\n{\n  \"name\": \"migration-husky-or-prepare\",\n  \"scripts\": {\n    \"prepare\": \"vp config || true\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-or-prepare/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should preserve || fallback semantics\",\n    \"cat package.json # check prepare script preserves || true fallback\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-semicolon-prepare/package.json",
    "content": "{\n  \"name\": \"migration-husky-semicolon-prepare\",\n  \"scripts\": {\n    \"prepare\": \"npm run build; husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should strip husky from semicolon-composed prepare script\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check husky removed from prepare script, not left as broken command\n{\n  \"name\": \"migration-husky-semicolon-prepare\",\n  \"scripts\": {\n    \"prepare\": \"npm run build; vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-semicolon-prepare/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should strip husky from semicolon-composed prepare script\",\n    \"cat package.json # check husky removed from prepare script, not left as broken command\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-husky-v8-preserves-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky install\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.{js,ts}\": \"eslint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should warn about husky v8, preserve lint-staged config\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # lint-staged config should still be in package.json\n{\n  \"name\": \"migration-husky-v8-preserves-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky install\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"lint-staged\": {\n    \"*.{js,ts}\": \"eslint --fix\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should warn about husky v8, preserve lint-staged config\",\n    \"cat package.json # lint-staged config should still be in package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-in-scripts/package.json",
    "content": "{\n  \"name\": \"migration-lint-staged-in-scripts\",\n  \"scripts\": {\n    \"prepare\": \"husky\",\n    \"check-staged\": \"lint-staged --diff HEAD~1\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"oxlint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should rewrite lint-staged commands in scripts\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat package.json # check-staged script should use vp staged, lint-staged removed from devDeps\n{\n  \"name\": \"migration-lint-staged-in-scripts\",\n  \"scripts\": {\n    \"prepare\": \"vp config\",\n    \"check-staged\": \"vp staged --diff HEAD~1\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.js\": \"vp lint --fix\"\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-in-scripts/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should rewrite lint-staged commands in scripts\",\n    \"cat package.json # check-staged script should use vp staged, lint-staged removed from devDeps\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-merge-fail/package.json",
    "content": "{\n  \"name\": \"migration-lint-staged-merge-fail\",\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.css\": \"stylelint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should handle merge failure gracefully\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n! Warnings:\n  - Failed to merge staged config into vite.config.ts\n  - Git hooks not configured — Failed to merge staged config into vite.config.ts\n\nPlease add staged config to vite.config.ts manually, see https://viteplus.dev/guide/migrate#lint-staged\n→ Manual follow-up:\n  - Please add staged config to vite.config.ts manually, see https://viteplus.dev/guide/migrate#lint-staged\n\n> cat package.json # lint-staged config should be preserved when merge fails\n{\n  \"name\": \"migration-lint-staged-merge-fail\",\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"lint-staged\": {\n    \"*.css\": \"stylelint --fix\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> cat vite.config.ts # vite config should be unchanged (merge failed)\nconst config = { plugins: [] };\nmodule.exports = config;\n\n> test -f .vite-hooks/pre-commit && echo 'pre-commit hook exists' || echo 'no pre-commit hook' # should NOT exist when merge fails\npre-commit hook exists\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-merge-fail/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should handle merge failure gracefully\",\n    \"cat package.json # lint-staged config should be preserved when merge fails\",\n    \"cat vite.config.ts # vite config should be unchanged (merge failed)\",\n    \"test -f .vite-hooks/pre-commit && echo 'pre-commit hook exists' || echo 'no pre-commit hook' # should NOT exist when merge fails\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-merge-fail/vite.config.ts",
    "content": "const config = { plugins: [] };\nmodule.exports = config;\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-ts-config/lint-staged.config.ts",
    "content": "export default {\n  \"*.{js,ts}\": [\"oxlint --fix\", \"oxfmt\"],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-ts-config/package.json",
    "content": "{\n  \"name\": \"migration-lint-staged-ts-config\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should warn about unsupported TS lint-staged config\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Unsupported lint-staged config format — skipping git hooks setup. Please configure git hooks manually.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # check lint-staged NOT added to package.json, husky/lint-staged removed from devDependencies\n{\n  \"name\": \"migration-lint-staged-ts-config\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat lint-staged.config.ts # check TS config is not modified\nexport default {\n  \"*.{js,ts}\": [\"oxlint --fix\", \"oxfmt\"],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lint-staged-ts-config/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should warn about unsupported TS lint-staged config\",\n    \"cat package.json # check lint-staged NOT added to package.json, husky/lint-staged removed from devDependencies\",\n    \"cat lint-staged.config.ts # check TS config is not modified\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-json/.lintstagedrc.json",
    "content": "{\n  \"*.js\": \"oxlint --fix\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-json/package.json",
    "content": "{\n  \"name\": \"migration-lintstagedrc\",\n  \"lint-staged\": {\n    \"*.@(js|ts|tsx|yml|yaml|md|json|html|toml)\": [\n      \"oxfmt --staged\",\n      \"eslint --fix\"\n    ],\n    \"*.@(js|ts|tsx)\": [\n      \"oxlint --fix\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt",
    "content": "> vp migrate -h # migration help message\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp migrate [PATH] [OPTIONS]\n\nMigrate standalone Vite, Vitest, Oxlint, Oxfmt, and Prettier projects to unified Vite+.\n\nArguments:\n  PATH  Target directory to migrate (default: current directory)\n\nOptions:\n  --agent NAME      Write agent instructions file into the project (e.g. chatgpt, claude, opencode).\n  --no-agent        Skip writing agent instructions file\n  --editor NAME     Write editor config files into the project.\n  --no-editor       Skip writing editor config files\n  --hooks           Set up pre-commit hooks (default in non-interactive mode)\n  --no-hooks        Skip pre-commit hooks setup\n  --no-interactive  Run in non-interactive mode (skip prompts and use defaults)\n  -h, --help        Show this help message\n\nExamples:\n  # Migrate current package\n  vp migrate\n\n  # Migrate specific directory\n  vp migrate my-app\n\n  # Non-interactive mode\n  vp migrate --no-interactive\n\nMigration Prompt:\n  Give this to a coding agent when you want it to drive the migration:\n\n  Migrate this project to Vite+.\n  Vite+ replaces the current split tooling around runtime management, package\n  management, dev/build/test commands, linting, formatting, and packaging.\n  Run `vp help` and `vp help migrate` before making changes.\n  Use vp migrate --no-interactive in the workspace root.\n  Make sure the project is using Vite 8+ and Vitest 4.1+ before migrating.\n\n  After the migration:\n  - Confirm `vite` imports were rewritten to `vite-plus` where needed\n  - Confirm `vitest` imports were rewritten to `vite-plus/test` where needed\n  - Remove old `vite` and `vitest` dependencies only after those rewrites\n    are confirmed\n  - Move remaining tool-specific config into the appropriate blocks in\n    `vite.config.ts`\n\n  Command mapping:\n  - `vp run <script>` is the equivalent of `pnpm run <script>`\n  - `vp test` runs the built-in test command, while `vp run test` runs the\n    `test` script from `package.json`\n  - `vp install`, `vp add`, and `vp remove` delegate through the package\n    manager declared by `packageManager`\n  - `vp dev`, `vp build`, `vp preview`, `vp lint`, `vp fmt`, `vp check`,\n    and `vp pack` replace the corresponding standalone tools\n  - Prefer `vp check` for validation loops\n\n  Finally, verify the migration by running:\n  - vp install\n  - vp check\n  - vp test\n  - vp build\n\n  Summarize the migration at the end and report any manual follow-up still\n  required.\n\nDocumentation: https://viteplus.dev/guide/migrate\n\n\n> vp migrate --no-interactive # migration work with lintstagedrc.json\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n! Warnings:\n  - .lintstagedrc.json found but \"staged\" already exists in vite.config.ts — please merge manually\n\n> cat .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining)\n{\n  \"*.js\": \"oxlint --fix\"\n}\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-lintstagedrc\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> cat vite.config.ts # check staged config migrated to vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.@(js|ts|tsx|yml|yaml|md|json|html|toml)\": [\n      \"vp fmt --staged\",\n      \"eslint --fix\"\n    ],\n    \"*.@(js|ts|tsx)\": [\n      \"vp lint --fix\"\n    ]\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-json/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate -h # migration help message\",\n    \"vp migrate --no-interactive # migration work with lintstagedrc.json\",\n    \"cat .lintstagedrc.json # check lintstagedrc.json (should be deleted after inlining)\",\n    \"cat package.json # check package.json\",\n    \"cat vite.config.ts # check staged config migrated to vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/.lintstagedrc.json",
    "content": "{\n  \"*.css\": \"stylelint --fix\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/package.json",
    "content": "{\n  \"name\": \"migration-lintstagedrc-merge-fail\",\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should handle merge failure gracefully\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n! Warnings:\n  - Failed to merge staged config into vite.config.ts\n  - Git hooks not configured — Failed to merge staged config into vite.config.ts\n\nPlease add staged config to vite.config.ts manually, see https://viteplus.dev/guide/migrate#lint-staged\n→ Manual follow-up:\n  - Please add staged config to vite.config.ts manually, see https://viteplus.dev/guide/migrate#lint-staged\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-lintstagedrc-merge-fail\",\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> cat .lintstagedrc.json # config file should be preserved when merge fails\n{\n  \"*.css\": \"stylelint --fix\"\n}\n\n> cat vite.config.ts # vite config should be unchanged (merge failed)\nconst config = { plugins: [] };\nmodule.exports = config;\n\n> test -f .vite-hooks/pre-commit && echo 'pre-commit hook exists' || echo 'no pre-commit hook' # should NOT exist when merge fails\npre-commit hook exists\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should handle merge failure gracefully\",\n    \"cat package.json # check package.json\",\n    \"cat .lintstagedrc.json # config file should be preserved when merge fails\",\n    \"cat vite.config.ts # vite config should be unchanged (merge failed)\",\n    \"test -f .vite-hooks/pre-commit && echo 'pre-commit hook exists' || echo 'no pre-commit hook' # should NOT exist when merge fails\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/vite.config.ts",
    "content": "const config = { plugins: [] };\nmodule.exports = config;\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-not-support/.lintstagedrc",
    "content": "'*.js':\n  - oxlint\n  - oxfmt\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-not-support/.lintstagedrc.yaml",
    "content": "'*.js':\n  - oxlint\n  - oxfmt\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-not-support/lint-staged.config.mjs",
    "content": "export default {\n  '*.js': ['oxlint', 'oxfmt'],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-not-support/package.json",
    "content": "{\n  \"name\": \"migration-lintstagedrc-not-support\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # migration should not support non-json format lintstagedrc\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Unsupported lint-staged config format — skipping git hooks setup. Please configure git hooks manually.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat .lintstagedrc # check .lintstagedrc is not updated\n'*.js':\n  - oxlint\n  - oxfmt\n\n> cat .lintstagedrc.yaml # check .lintstagedrc.yaml is not updated\n'*.js':\n  - oxlint\n  - oxfmt\n\n> cat lint-staged.config.mjs # check lint-staged.config.mjs is not updated\nexport default {\n  '*.js': ['oxlint', 'oxfmt'],\n};\n\n> cat package.json # check hooks setup skipped but husky/lint-staged removed from devDependencies\n{\n  \"name\": \"migration-lintstagedrc-not-support\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-not-support/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # migration should not support non-json format lintstagedrc\",\n    \"cat .lintstagedrc # check .lintstagedrc is not updated\",\n    \"cat .lintstagedrc.yaml # check .lintstagedrc.yaml is not updated\",\n    \"cat lint-staged.config.mjs # check lint-staged.config.mjs is not updated\",\n    \"cat package.json # check hooks setup skipped but husky/lint-staged removed from devDependencies\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/.lintstagedrc.json",
    "content": "{\n  \"*.css\": \"stylelint --fix\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/package.json",
    "content": "{\n  \"name\": \"migration-lintstagedrc-staged-exists\",\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should warn when staged already exists in vite.config.ts\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• Git hooks configured\n! Warnings:\n  - .lintstagedrc.json found but \"staged\" already exists in vite.config.ts — please merge manually\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-lintstagedrc-staged-exists\",\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> test -f .lintstagedrc.json && echo 'lintstagedrc.json still exists' || echo 'lintstagedrc.json was deleted' # should still exist\nlintstagedrc.json still exists\n\n> cat vite.config.ts # vite config should be unchanged\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    '*.js': 'vp check --fix',\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should warn when staged already exists in vite.config.ts\",\n    \"cat package.json # check package.json\",\n    \"test -f .lintstagedrc.json && echo 'lintstagedrc.json still exists' || echo 'lintstagedrc.json was deleted' # should still exist\",\n    \"cat vite.config.ts # vite config should be unchanged\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/vite.config.ts",
    "content": "import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    '*.js': 'vp check --fix',\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-js/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-js/package.json",
    "content": "{\n  \"scripts\": {\n    \"dev\": \"vite --port 3000\",\n    \"build\": \"vite build\",\n    \"lint\": \"oxlint\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"^4.2.0\",\n    \"oxlint\": \"1\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt",
    "content": "> vp migrate --no-interactive # migration should merge vite.config.js and remove oxlintrc\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> cat vite.config.js # check vite.config.js\nimport react from '@vitejs/plugin-react';\n\nexport default {\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  plugins: [react()],\n}\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> cat package.json # check package.json\n{\n  \"scripts\": {\n    \"dev\": \"vp dev --port 3000\",\n    \"build\": \"vp build\",\n    \"lint\": \"vp lint\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"^4.2.0\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-js/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should merge vite.config.js and remove oxlintrc\",\n    \"cat vite.config.js # check vite.config.js\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-js/vite.config.js",
    "content": "import react from '@vitejs/plugin-react';\n\nexport default {\n  plugins: [react()],\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-ts/.oxfmtrc.json",
    "content": "{\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-ts/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-ts/package.json",
    "content": "{\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"test:run\": \"vitest run\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest --watch\",\n    \"test\": \"vitest\",\n    \"lint\": \"oxlint\",\n    \"lint:fix\": \"oxlint --fix\",\n    \"lint:type-aware\": \"oxlint --type-aware\",\n    \"fmt\": \"oxfmt\",\n    \"fmt:fix\": \"oxfmt --fix\",\n    \"fmt:staged\": \"oxfmt --staged\",\n    \"fmt:staged:fix\": \"oxfmt --staged --fix\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"^4.2.0\",\n    \"@vitest/browser-playwright\": \"^4.0.0\",\n    \"oxfmt\": \"1\",\n    \"oxlint\": \"1\",\n    \"vite\": \"^7.0.0\",\n    \"vitest\": \"^4.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt",
    "content": "> vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc and oxfmtrc\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 3 config updates applied, 1 file had imports rewritten\n\n> cat vite.config.ts # check vite.config.ts\nimport { join } from 'node:path';\n\nimport react from '@vitejs/plugin-react';\nimport { playwright } from 'vite-plus/test/browser-playwright';\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  fmt: {\n    \"printWidth\": 100,\n    \"tabWidth\": 2,\n    \"semi\": true,\n    \"singleQuote\": true,\n    \"trailingComma\": \"es5\"\n  },\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  plugins: [react()],\n  test: {\n    dir: join(import.meta.dirname, 'test'),\n    browser: {\n      enabled: true,\n      provider: playwright(),\n      headless: true,\n      screenshotFailures: false,\n      instances: [{ browser: 'chromium' }],\n    },\n  },\n});\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\ncat: .oxfmtrc.json: No such file or directory\n\n> cat package.json # check package.json\n{\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test:run\": \"vp test run\",\n    \"test:ui\": \"vp test --ui\",\n    \"test:coverage\": \"vp test run --coverage\",\n    \"test:watch\": \"vp test --watch\",\n    \"test\": \"vp test\",\n    \"lint\": \"vp lint\",\n    \"lint:fix\": \"vp lint --fix\",\n    \"lint:type-aware\": \"vp lint --type-aware\",\n    \"fmt\": \"vp fmt\",\n    \"fmt:fix\": \"vp fmt --fix\",\n    \"fmt:staged\": \"vp fmt --staged\",\n    \"fmt:staged:fix\": \"vp fmt --staged --fix\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"^4.2.0\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-ts/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc and oxfmtrc\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-merge-vite-config-ts/vite.config.ts",
    "content": "import { join } from 'node:path';\n\nimport react from '@vitejs/plugin-react';\nimport { playwright } from '@vitest/browser-playwright';\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  plugins: [react()],\n  test: {\n    dir: join(import.meta.dirname, 'test'),\n    browser: {\n      enabled: true,\n      provider: playwright(),\n      headless: true,\n      screenshotFailures: false,\n      instances: [{ browser: 'chromium' }],\n    },\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-husky-v8-preserves-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-monorepo-husky-v8-preserves-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky install\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"catalog:\"\n  },\n  \"lint-staged\": {\n    \"*.{js,ts}\": \"eslint --fix\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-husky-v8-preserves-lint-staged/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"devDependencies\": {\n    \"vite\": \"catalog:\"\n  },\n  \"lint-staged\": {\n    \"*.css\": \"stylelint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-husky-v8-preserves-lint-staged/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n\ncatalog:\n  vite: ^7.0.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-husky-v8-preserves-lint-staged/snap.txt",
    "content": "> vp migrate --no-interactive # should warn about husky v8, preserve all lint-staged config\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # root lint-staged config should still be in package.json\n{\n  \"name\": \"migration-monorepo-husky-v8-preserves-lint-staged\",\n  \"scripts\": {\n    \"prepare\": \"husky install\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"lint-staged\": {\n    \"*.{js,ts}\": \"eslint --fix\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat packages/app/package.json # app lint-staged config should still be in package.json\n{\n  \"name\": \"app\",\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"lint-staged\": {\n    \"*.css\": \"stylelint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-husky-v8-preserves-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # should warn about husky v8, preserve all lint-staged config\",\n    \"cat package.json # root lint-staged config should still be in package.json\",\n    \"cat packages/app/package.json # app lint-staged config should still be in package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/.oxfmtrc.json",
    "content": "{\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/package.json",
    "content": "{\n  \"name\": \"migration-monorepo-pnpm\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"test:run\": \"vitest run\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest --watch\",\n    \"test\": \"vitest\",\n    \"lint\": \"oxlint\",\n    \"fmt\": \"oxfmt\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"catalog:\",\n    \"oxfmt\": \"catalog:\",\n    \"oxlint\": \"catalog:\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  },\n  \"resolutions\": {\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vue\": \"3.5.25\"\n  },\n  \"packageManager\": \"pnpm@10.18.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"test\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/packages/only-oxlint/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"warn\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/packages/only-oxlint/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/only-oxlint\",\n  \"scripts\": {\n    \"lint\": \"oxlint --fix\"\n  },\n  \"devDependencies\": {\n    \"oxlint\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"test\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n\ncatalog:\n  testnpm2: ^1.0.0\n  # test comment here to check if the comment is preserved\n  vite: ^7.0.0\n  vitest: ^4.0.0\n  oxlint: ^1.0.0\n  oxfmt: ^1.0.0\n  oxlint-tsgolint: ^1.0.0\n\nminimumReleaseAge: 1440\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt",
    "content": "> vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc and oxfmtrc\nVITE+ - The Unified Toolchain for the Web\n\n\n✔ Merged .oxlintrc.json into vite.config.ts\n\n✔ Merged .oxfmtrc.json into vite.config.ts\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 3 config updates applied, 1 file had imports rewritten\n\n> cat vite.config.ts # check vite.config.ts\nimport react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  fmt: {\n    \"printWidth\": 100,\n    \"tabWidth\": 2,\n    \"semi\": true,\n    \"singleQuote\": true,\n    \"trailingComma\": \"es5\"\n  },\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  plugins: [react()],\n});\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\ncat: .oxfmtrc.json: No such file or directory\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-monorepo-pnpm\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test:run\": \"vp test run\",\n    \"test:ui\": \"vp test --ui\",\n    \"test:coverage\": \"vp test run --coverage\",\n    \"test:watch\": \"vp test --watch\",\n    \"test\": \"vp test\",\n    \"lint\": \"vp lint\",\n    \"fmt\": \"vp fmt\",\n    \"prepare\": \"vp config\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"catalog:\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"resolutions\": {\n    \"vue\": \"3.5.25\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat pnpm-workspace.yaml # check pnpm-workspace.yaml\npackages:\n  - packages/*\n\ncatalog:\n  testnpm2: ^1.0.0\n  # test comment here to check if the comment is preserved\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n  vite-plus: latest\n\nminimumReleaseAge: 1440\noverrides:\n  vite: 'catalog:'\n  vitest: 'catalog:'\npeerDependencyRules:\n  allowAny:\n    - vite\n    - vitest\n  allowedVersions:\n    vite: '*'\n    vitest: '*'\nminimumReleaseAgeExclude:\n  - vite-plus\n  - '@voidzero-dev/*'\n  - oxlint\n  - '@oxlint/*'\n  - oxlint-tsgolint\n  - '@oxlint-tsgolint/*'\n  - oxfmt\n  - '@oxfmt/*'\n\n> cat packages/app/package.json # check app package.json\n{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test\": \"vp test\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n\n> cat packages/utils/package.json # check utils package.json\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test\": \"vp test\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  }\n}\n\n> cat packages/only-oxlint/package.json # check only-oxlint package.json\n{\n  \"name\": \"@vite-plus-test/only-oxlint\",\n  \"scripts\": {\n    \"lint\": \"vp lint --fix\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"catalog:\"\n  }\n}\n\n> cat packages/only-oxlint/vite.config.ts # check only-oxlint vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"warn\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  \n});\n\n> cat packages/only-oxlint/.oxlintrc.json && exit 1 || true # check only-oxlint .oxlintrc.json is removed\ncat: packages/only-oxlint/.oxlintrc.json: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc and oxfmtrc\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"cat .oxfmtrc.json && exit 1 || true # check .oxfmtrc.json is removed\",\n    \"cat package.json # check package.json\",\n    \"cat pnpm-workspace.yaml # check pnpm-workspace.yaml\",\n    \"cat packages/app/package.json # check app package.json\",\n    \"cat packages/utils/package.json # check utils package.json\",\n    \"cat packages/only-oxlint/package.json # check only-oxlint package.json\",\n    \"cat packages/only-oxlint/vite.config.ts # check only-oxlint vite.config.ts\",\n    \"cat packages/only-oxlint/.oxlintrc.json && exit 1 || true # check only-oxlint .oxlintrc.json is removed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  plugins: [react()],\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/package.json",
    "content": "{\n  \"name\": \"migration-monorepo-pnpm-overrides-dependency-selector\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"dev\": \"vite\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"catalog:\",\n    \"vite\": \"catalog:\"\n  },\n  \"packageManager\": \"pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"react-click-away-listener>react\": \"0.0.0-experimental-7dc903cd-20251203\",\n      \"vite\": \"npm:vite@7.0.12\",\n      \"vite-plugin-inspect>vite\": \"npm:vite@7.0.12\",\n      \"vite-plugin-svgr>foo>vite\": \"npm:vite@7.0.12\",\n      \"@vitejs/plugin-react>vite\": \"npm:vite@7.0.12\",\n      \"@vitejs/plugin-react-swc>vite\": \"npm:vite@7.0.12\",\n      \"supertest>superagent\": \"9.0.2\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vite --port 3000\",\n    \"build\": \"vite build\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n\ncatalog:\n  vite: 'npm:vite@7.0.12'\n\noverrides:\n  'vite-plugin-svgr>vite': 'npm:vite@7.0.12'\n  '@vitejs/plugin-react>vite': 'npm:vite@7.0.12'\n  'vite-plugin-svgr>foo>vite': 'npm:vite@7.0.12'\n  'supertest>superagent': '9.0.2'\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/snap.txt",
    "content": "> vp migrate --no-interactive # migration should merge pnpm overrides with dependency selector\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied, 1 file had imports rewritten\n\n> cat vite.config.ts # check vite.config.ts\nimport react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  plugins: [react()],\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-monorepo-pnpm-overrides-dependency-selector\",\n  \"version\": \"1.0.0\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"catalog:\",\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"packageManager\": \"pnpm@<semver>+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"react-click-away-listener>react\": \"0.0.0-experimental-7dc903cd-20251203\",\n      \"supertest>superagent\": \"9.0.2\"\n    }\n  }\n}\n\n> cat pnpm-workspace.yaml # check pnpm-workspace.yaml\npackages:\n  - packages/*\n\ncatalog:\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n  vite-plus: latest\n\noverrides:\n  '@vitejs/plugin-react>vite': 'npm:vite@<semver>'\n  'supertest>superagent': '9.0.2'\n  vite: 'catalog:'\n  vitest: 'catalog:'\npeerDependencyRules:\n  allowAny:\n    - vite\n    - vitest\n  allowedVersions:\n    vite: '*'\n    vitest: '*'\n\n> cat packages/app/package.json # check app package.json\n{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vp dev --port 3000\",\n    \"build\": \"vp build\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should merge pnpm overrides with dependency selector\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat package.json # check package.json\",\n    \"cat pnpm-workspace.yaml # check pnpm-workspace.yaml\",\n    \"cat packages/app/package.json # check app package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-pnpm-overrides-dependency-selector/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  plugins: [react()],\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/package.json",
    "content": "{\n  \"name\": \"migration-monorepo-skip-vite-peer-dependency\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/packages/vite-plugin/package.json",
    "content": "{\n  \"name\": \"my-vite-plugin\",\n  \"peerDependencies\": {\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/packages/vite-plugin/src/index.ts",
    "content": "import { defineConfig, type Plugin } from 'vite';\nimport { describe, it, expect } from 'vitest';\n\nexport function myVitePlugin(): Plugin {\n  return {\n    name: 'my-vite-plugin',\n    configResolved(config) {\n      console.log(config);\n    },\n  };\n}\n\ndescribe('myVitePlugin', () => {\n  it('should work', () => {\n    expect(myVitePlugin()).toBeDefined();\n  });\n});\n\nexport default defineConfig({\n  plugins: [myVitePlugin()],\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt",
    "content": "> vp migrate --no-interactive # migration should check each package's peerDependencies\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat packages/vite-plugin/src/index.ts # vite-plugin has vite in peerDeps: vite NOT rewritten, vitest rewritten\nimport { defineConfig, type Plugin } from 'vite';\nimport { describe, it, expect } from 'vite-plus/test';\n\nexport function myVitePlugin(): Plugin {\n  return {\n    name: 'my-vite-plugin',\n    configResolved(config) {\n      console.log(config);\n    },\n  };\n}\n\ndescribe('myVitePlugin', () => {\n  it('should work', () => {\n    expect(myVitePlugin()).toBeDefined();\n  });\n});\n\nexport default defineConfig({\n  plugins: [myVitePlugin()],\n});\n\n> cat package.json # check root package.json (no peerDependencies)\n{\n  \"name\": \"migration-monorepo-skip-vite-peer-dependency\",\n  \"devDependencies\": {\n    \"vite-plus\": \"catalog:\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> cat packages/vite-plugin/package.json # has vite in peerDependencies\n{\n  \"name\": \"my-vite-plugin\",\n  \"peerDependencies\": {\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should check each package's peerDependencies\",\n    \"cat packages/vite-plugin/src/index.ts # vite-plugin has vite in peerDeps: vite NOT rewritten, vitest rewritten\",\n    \"cat package.json # check root package.json (no peerDependencies)\",\n    \"cat packages/vite-plugin/package.json # has vite in peerDependencies\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/.oxlintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-unused-vars\": \"error\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/package.json",
    "content": "{\n  \"name\": \"migration-monorepo-yarn4\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"test:run\": \"vitest run\",\n    \"test:ui\": \"vitest --ui\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest --watch\",\n    \"test\": \"vitest\",\n    \"lint\": \"oxlint\",\n    \"fmt\": \"oxfmt\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"catalog:\",\n    \"oxfmt\": \"catalog:\",\n    \"oxlint\": \"catalog:\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  },\n  \"packageManager\": \"yarn@4.12.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"test\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/packages/utils/package.json",
    "content": "{\n  \"name\": \"@vite-plus-test/utils\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"vite build\",\n    \"test\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/snap.txt",
    "content": "> vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc\nVITE+ - The Unified Toolchain for the Web\n\n\n✔ Merged .oxlintrc.json into vite.config.ts\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  yarn <semver>\n• 1 config update applied, 1 file had imports rewritten\n\n> cat vite.config.ts # check vite.config.ts\nimport react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  plugins: [react()],\n});\n\n> cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\ncat: .oxlintrc.json: No such file or directory\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-monorepo-yarn4\",\n  \"version\": \"1.0.0\",\n  \"workspaces\": [\n    \"packages/*\"\n  ],\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test:run\": \"vp test run\",\n    \"test:ui\": \"vp test --ui\",\n    \"test:coverage\": \"vp test run --coverage\",\n    \"test:watch\": \"vp test --watch\",\n    \"test\": \"vp test\",\n    \"lint\": \"vp lint\",\n    \"fmt\": \"vp fmt\",\n    \"prepare\": \"vp config\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"catalog:\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"packageManager\": \"yarn@<semver>\",\n  \"resolutions\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  }\n}\n\n> cat .yarnrc.yml # check .yarnrc.yml\ncatalog:\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n  vite-plus: latest\n\n> cat packages/app/package.json # check app package.json\n{\n  \"name\": \"app\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test\": \"vp test\"\n  },\n  \"dependencies\": {\n    \"@vite-plus-test/utils\": \"workspace:*\",\n    \"test-vite-plus-install\": \"1.0.0\",\n    \"testnpm2\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"test-vite-plus-package\": \"1.0.0\",\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  },\n  \"optionalDependencies\": {\n    \"test-vite-plus-other-optional\": \"1.0.0\"\n  }\n}\n\n> cat packages/utils/package.json # check utils package.json\n{\n  \"name\": \"@vite-plus-test/utils\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"test\": \"vp test\"\n  },\n  \"dependencies\": {\n    \"testnpm2\": \"1.0.0\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\",\n    \"vite-plus\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should merge vite.config.ts and remove oxlintrc\",\n    \"cat vite.config.ts # check vite.config.ts\",\n    \"cat .oxlintrc.json && exit 1 || true # check .oxlintrc.json is removed\",\n    \"cat package.json # check package.json\",\n    \"cat .yarnrc.yml # check .yarnrc.yml\",\n    \"cat packages/app/package.json # check app package.json\",\n    \"cat packages/utils/package.json # check utils package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-monorepo-yarn4/vite.config.ts",
    "content": "import react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  plugins: [react()],\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-agent/package.json",
    "content": "{\n  \"name\": \"migration-no-agent\",\n  \"dependencies\": {\n    \"vitest\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-agent/snap.txt",
    "content": "> vp migrate --no-agent --no-interactive # migration with --no-agent should skip agent instructions\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> ls -la | grep -E '(AGENTS|CLAUDE)' || echo 'No agent file created' # verify no agent file was created\nNo agent file created\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-agent/src/index.ts",
    "content": "import { describe, it, expect } from 'vitest';\n\ndescribe('example', () => {\n  it('should work', () => {\n    expect(1 + 1).toBe(2);\n  });\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-agent/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-agent --no-interactive # migration with --no-agent should skip agent instructions\",\n    \"ls -la | grep -E '(AGENTS|CLAUDE)' || echo 'No agent file created' # verify no agent file was created\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-git-repo/package.json",
    "content": "{\n  \"name\": \"migration-no-git-repo\",\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-git-repo/snap.txt",
    "content": "> vp migrate --no-interactive # migration should create .vite-hooks/pre-commit even without .git\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> cat package.json # check package.json has prepare script and lint-staged config\n{\n  \"name\": \"migration-no-git-repo\",\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n\n> test -d .vite-hooks && echo 'hooks dir exists' || echo 'no hooks dir'\nhooks dir exists\n\n> cat .vite-hooks/pre-commit # pre-commit hook should exist even without .git\nvp staged\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-git-repo/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should create .vite-hooks/pre-commit even without .git\",\n    \"cat package.json # check package.json has prepare script and lint-staged config\",\n    \"test -d .vite-hooks && echo 'hooks dir exists' || echo 'no hooks dir'\",\n    \"cat .vite-hooks/pre-commit # pre-commit hook should exist even without .git\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-hooks/package.json",
    "content": "{\n  \"name\": \"migration-no-hooks\",\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-hooks/snap.txt",
    "content": "> git init\n> vp migrate --no-hooks --no-interactive # migration with --no-hooks should skip hooks setup\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # check package.json has no prepare script and no lint-staged config\n{\n  \"name\": \"migration-no-hooks\",\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> test -d .vite-hooks && echo '.vite-hooks directory exists' || echo 'No .vite-hooks directory' # verify no .vite-hooks directory\nNo .vite-hooks directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-hooks/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-hooks --no-interactive # migration with --no-hooks should skip hooks setup\",\n    \"cat package.json # check package.json has no prepare script and no lint-staged config\",\n    \"test -d .vite-hooks && echo '.vite-hooks directory exists' || echo 'No .vite-hooks directory' # verify no .vite-hooks directory\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-hooks-with-husky/package.json",
    "content": "{\n  \"name\": \"migration-no-hooks-with-husky\",\n  \"scripts\": {\n    \"prepare\": \"husky\",\n    \"check-staged\": \"lint-staged\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.ts\": \"eslint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt",
    "content": "> git init\n> vp migrate --no-hooks --no-interactive # --no-hooks should keep husky/lint-staged and preserve config\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # prepare script, lint-staged config, check-staged script, and deps should all be preserved\n{\n  \"name\": \"migration-no-hooks-with-husky\",\n  \"scripts\": {\n    \"prepare\": \"husky\",\n    \"check-staged\": \"lint-staged\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.2.6\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"lint-staged\": {\n    \"*.ts\": \"eslint --fix\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # verify no .husky directory\nNo .husky directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-no-hooks-with-husky/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-hooks --no-interactive # --no-hooks should keep husky/lint-staged and preserve config\",\n    \"cat package.json # prepare script, lint-staged config, check-staged script, and deps should all be preserved\",\n    \"test -d .husky && echo '.husky directory exists' || echo 'No .husky directory' # verify no .husky directory\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-npm8.2/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  },\n  \"packageManager\": \"npm@8.2.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-npm8.2/snap.txt",
    "content": "[1]> vp migrate --no-interactive # migration should fail because npm version is not supported\nVITE+ - The Unified Toolchain for the Web\n\n\n✘ npm@<semver> is not supported by auto migration, please upgrade npm to >=8.3.0 first\nVite+ cannot automatically migrate this project yet.\n\n\n> cat package.json # check package.json is not updated\n{\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  },\n  \"packageManager\": \"npm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-npm8.2/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should fail because npm version is not supported\",\n    \"cat package.json # check package.json is not updated\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-pnpm9.4/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  },\n  \"packageManager\": \"pnpm@9.4.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-pnpm9.4/snap.txt",
    "content": "[1]> vp migrate --no-interactive # migration should fail because pnpm version is not supported\nVITE+ - The Unified Toolchain for the Web\n\n\n✘ pnpm@<semver> is not supported by auto migration, please upgrade pnpm to >=9.5.0 first\nVite+ cannot automatically migrate this project yet.\n\n\n> cat package.json # check package.json is not updated\n{\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-pnpm9.4/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should fail because pnpm version is not supported\",\n    \"cat package.json # check package.json is not updated\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-vite6/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-vite6/snap.txt",
    "content": "> vp install # install dependencies first\n[1]> vp migrate --no-interactive # migration should fail because vite version is not supported\nVITE+ - The Unified Toolchain for the Web\n\n\n✘ vite@<semver> in package.json is not supported by auto migration\n\nPlease upgrade vite to version >=7.0.0 first\nVite+ cannot automatically migrate this project yet.\n\n\n> cat package.json # check package.json is not updated\n{\n  \"devDependencies\": {\n    \"vite\": \"^6.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-vite6/steps.json",
    "content": "{\n  \"commands\": [\n    {\n      \"command\": \"vp install # install dependencies first\",\n      \"ignoreOutput\": true\n    },\n    \"vp migrate --no-interactive # migration should fail because vite version is not supported\",\n    \"cat package.json # check package.json is not updated\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-vitest3/package.json",
    "content": "{\n  \"devDependencies\": {\n    \"vitest\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-vitest3/snap.txt",
    "content": "> vp install # install dependencies first\n[1]> vp migrate --no-interactive # migration should fail because vitest version is not supported\nVITE+ - The Unified Toolchain for the Web\n\n\n✘ vitest@<semver> in package.json is not supported by auto migration\n\nPlease upgrade vitest to version >=4.0.0 first\nVite+ cannot automatically migrate this project yet.\n\n\n> cat package.json # check package.json is not updated\n{\n  \"devDependencies\": {\n    \"vitest\": \"^3.0.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-not-supported-vitest3/steps.json",
    "content": "{\n  \"commands\": [\n    {\n      \"command\": \"vp install # install dependencies first\",\n      \"ignoreOutput\": true\n    },\n    \"vp migrate --no-interactive # migration should fail because vitest version is not supported\",\n    \"cat package.json # check package.json is not updated\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-other-hook-tool/package.json",
    "content": "{\n  \"name\": \"migration-other-hook-tool-with-lint-staged\",\n  \"scripts\": {\n    \"check-staged\": \"lint-staged\"\n  },\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"simple-git-hooks\": \"^2.11.1\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"simple-git-hooks\": {\n    \"pre-commit\": \"npx lint-staged\"\n  },\n  \"lint-staged\": {\n    \"*.ts\": \"eslint --fix\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt",
    "content": "> vp migrate --no-interactive # hooks should be skipped due to simple-git-hooks\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Detected simple-git-hooks — skipping git hooks setup. Please configure git hooks manually.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # lint-staged config, scripts, and simple-git-hooks config should all be preserved\n{\n  \"name\": \"migration-other-hook-tool-with-lint-staged\",\n  \"scripts\": {\n    \"check-staged\": \"lint-staged\"\n  },\n  \"devDependencies\": {\n    \"lint-staged\": \"^16.2.6\",\n    \"simple-git-hooks\": \"^2.11.1\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"simple-git-hooks\": {\n    \"pre-commit\": \"npx lint-staged\"\n  },\n  \"lint-staged\": {\n    \"*.ts\": \"eslint --fix\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-other-hook-tool/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # hooks should be skipped due to simple-git-hooks\",\n    \"cat package.json # lint-staged config, scripts, and simple-git-hooks config should all be preserved\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/.husky/pre-commit",
    "content": ". \"$(dirname -- \"$0\")/_/husky.sh\"\n\nvp staged\nnpm test\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/package.json",
    "content": "{\n  \"name\": \"migration-partially-migrated-pre-commit\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt",
    "content": "> git init\n> vp migrate --no-interactive # should warn about husky v8 and skip hooks setup\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat package.json # husky/lint-staged should remain in devDeps, prepare should stay as husky\n{\n  \"name\": \"migration-partially-migrated-pre-commit\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^8.0.0\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .husky/pre-commit # hook file should be unchanged (still has bootstrap)\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nvp staged\nnpm test\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate --no-interactive # should warn about husky v8 and skip hooks setup\",\n    \"cat package.json # husky/lint-staged should remain in devDeps, prepare should stay as husky\",\n    \"cat .husky/pre-commit # hook file should be unchanged (still has bootstrap)\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-pre-commit-env-setup/package.json",
    "content": "{\n  \"name\": \"migration-pre-commit-env-setup\",\n  \"scripts\": {\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^15.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-pre-commit-env-setup/snap.txt",
    "content": "> git init\n> mkdir -p .husky && printf '#!/usr/bin/env sh\\nexport NODE_OPTIONS=\"--max-old-space-size=4096\"\\nnpx lint-staged\\nnpm test\\n' > .husky/pre-commit && chmod 755 .husky/pre-commit\n> cat .husky/pre-commit # check pre-commit hook before migration\n#!/usr/bin/env sh\nexport NODE_OPTIONS=\"--max-old-space-size=4096\"\nnpx lint-staged\nnpm test\n\n> vp migrate --no-interactive # migration should replace lint-staged in-place\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Git hooks configured\n\n> cat .vite-hooks/pre-commit # check vp staged replaced npx lint-staged in-place\n#!/usr/bin/env sh\nexport NODE_OPTIONS=\"--max-old-space-size=4096\"\nvp staged\nnpm test\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-pre-commit-env-setup/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    {\n      \"command\": \"mkdir -p .husky && printf '#!/usr/bin/env sh\\\\nexport NODE_OPTIONS=\\\"--max-old-space-size=4096\\\"\\\\nnpx lint-staged\\\\nnpm test\\\\n' > .husky/pre-commit && chmod 755 .husky/pre-commit\",\n      \"ignoreOutput\": true\n    },\n    \"cat .husky/pre-commit # check pre-commit hook before migration\",\n    \"vp migrate --no-interactive # migration should replace lint-staged in-place\",\n    \"cat .vite-hooks/pre-commit # check vp staged replaced npx lint-staged in-place\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier/package.json",
    "content": "{\n  \"name\": \"migration-prettier\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier --check .\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect prettier and auto-migrate\nVITE+ - The Unified Toolchain for the Web\n\n\nPrettier configuration detected. Auto-migrating to Oxfmt...\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Prettier migrated to Oxfmt\n\n> cat package.json # check prettier removed and scripts rewritten\n{\n  \"name\": \"migration-prettier\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"format\": \"vp fmt .\",\n    \"format:check\": \"vp fmt --check .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .prettierrc.json && exit 1 || true # check prettier config is removed\ncat: .prettierrc.json: No such file or directory\n\n> cat vite.config.ts # check oxfmt config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  fmt: {\n    semi: true,\n    singleQuote: true,\n    printWidth: 80,\n    sortPackageJson: false,\n    ignorePatterns: [],\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect prettier and auto-migrate\",\n    \"cat package.json # check prettier removed and scripts rewritten\",\n    \"cat .prettierrc.json && exit 1 || true # check prettier config is removed\",\n    \"cat vite.config.ts # check oxfmt config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-eslint-combo/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-eslint-combo/eslint.config.mjs",
    "content": "export default [\n  {\n    rules: {\n      'no-unused-vars': 'error',\n    },\n  },\n];\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-eslint-combo/package.json",
    "content": "{\n  \"name\": \"migration-prettier-eslint-combo\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"lint\": \"eslint .\",\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier --check .\"\n  },\n  \"devDependencies\": {\n    \"eslint\": \"^9.0.0\",\n    \"prettier\": \"^3.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect both eslint and prettier and auto-migrate\nVITE+ - The Unified Toolchain for the Web\n\n\nPrettier configuration detected. Auto-migrating to Oxfmt...\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 4 config updates applied\n• ESLint rules migrated to Oxlint\n• Prettier migrated to Oxfmt\n\n> cat package.json # check eslint and prettier removed, scripts rewritten\n{\n  \"name\": \"migration-prettier-eslint-combo\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"lint\": \"vp lint .\",\n    \"format\": \"vp fmt .\",\n    \"format:check\": \"vp fmt --check .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  }\n}\n\n> cat eslint.config.mjs && exit 1 || true # check eslint config is removed\ncat: eslint.config.mjs: No such file or directory\n\n> cat .prettierrc.json && exit 1 || true # check prettier config is removed\ncat: .prettierrc.json: No such file or directory\n\n> cat vite.config.ts # check oxlint and oxfmt config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\n    \"plugins\": [\n      \"oxc\",\n      \"typescript\",\n      \"unicorn\",\n      \"react\"\n    ],\n    \"categories\": {\n      \"correctness\": \"warn\"\n    },\n    \"env\": {\n      \"builtin\": true\n    },\n    \"rules\": {\n      \"no-unused-vars\": \"error\"\n    },\n    \"options\": {\n      \"typeAware\": true,\n      \"typeCheck\": true\n    }\n  },\n  fmt: {\n    semi: true,\n    singleQuote: true,\n    printWidth: 80,\n    sortPackageJson: false,\n    ignorePatterns: [],\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-eslint-combo/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect both eslint and prettier and auto-migrate\",\n    \"cat package.json # check eslint and prettier removed, scripts rewritten\",\n    \"cat eslint.config.mjs && exit 1 || true # check eslint config is removed\",\n    \"cat .prettierrc.json && exit 1 || true # check prettier config is removed\",\n    \"cat vite.config.ts # check oxlint and oxfmt config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-ignore-unknown/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-ignore-unknown/package.json",
    "content": "{\n  \"name\": \"migration-prettier-ignore-unknown\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"format\": \"prettier . --cache --write --ignore-unknown --experimental-cli\",\n    \"format:check\": \"prettier --check -u .\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.0.0\",\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt",
    "content": "> vp migrate --no-interactive # migration should strip --ignore-unknown and -u flags\nVITE+ - The Unified Toolchain for the Web\n\n\nPrettier configuration detected. Auto-migrating to Oxfmt...\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Prettier migrated to Oxfmt\n\n> cat package.json # check prettier removed and --ignore-unknown stripped from scripts\n{\n  \"name\": \"migration-prettier-ignore-unknown\",\n  \"scripts\": {\n    \"dev\": \"vp dev\",\n    \"build\": \"vp build\",\n    \"format\": \"vp fmt .\",\n    \"format:check\": \"vp fmt --check .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat .prettierrc.json && exit 1 || true # check prettier config is removed\ncat: .prettierrc.json: No such file or directory\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-ignore-unknown/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should strip --ignore-unknown and -u flags\",\n    \"cat package.json # check prettier removed and --ignore-unknown stripped from scripts\",\n    \"cat .prettierrc.json && exit 1 || true # check prettier config is removed\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-lint-staged/.prettierrc.json",
    "content": "{\n  \"semi\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-lint-staged/package.json",
    "content": "{\n  \"name\": \"migration-prettier-lint-staged\",\n  \"scripts\": {\n    \"format\": \"prettier --write .\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.0.0\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"lint-staged\": {\n    \"*.ts\": \"prettier --write\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect prettier and auto-migrate including lint-staged\nVITE+ - The Unified Toolchain for the Web\n\n\nPrettier configuration detected. Auto-migrating to Oxfmt...\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n• Prettier migrated to Oxfmt\n\n> cat package.json # check prettier removed, scripts rewritten, lint-staged rewritten\n{\n  \"name\": \"migration-prettier-lint-staged\",\n  \"scripts\": {\n    \"format\": \"vp fmt .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check oxfmt config and staged config merged into vite.config.ts\nimport { defineConfig } from \"vite-plus\";\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  staged: {\n    \"*.ts\": \"vp fmt\"\n  },\n  fmt: {\n    semi: true,\n    printWidth: 80,\n    sortPackageJson: false,\n    ignorePatterns: [],\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-lint-staged/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect prettier and auto-migrate including lint-staged\",\n    \"cat package.json # check prettier removed, scripts rewritten, lint-staged rewritten\",\n    \"cat vite.config.ts # check oxfmt config and staged config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-pkg-json/package.json",
    "content": "{\n  \"name\": \"migration-prettier-pkg-json\",\n  \"scripts\": {\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier --check .\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.0.0\",\n    \"vite\": \"^7.0.0\"\n  },\n  \"prettier\": {\n    \"semi\": true,\n    \"singleQuote\": true\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt",
    "content": "> vp migrate --no-interactive # migration should detect prettier in package.json and auto-migrate\nVITE+ - The Unified Toolchain for the Web\n\n\nPrettier configuration detected. Auto-migrating to Oxfmt...\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n• Prettier migrated to Oxfmt\n\n> cat package.json # check prettier key removed, scripts rewritten, dep removed\n{\n  \"name\": \"migration-prettier-pkg-json\",\n  \"scripts\": {\n    \"format\": \"vp fmt .\",\n    \"format:check\": \"vp fmt --check .\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite.config.ts # check oxfmt config merged into vite.config.ts with semi/singleQuote settings\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  staged: {\n    \"*\": \"vp check --fix\"\n  },\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  fmt: {\n    semi: true,\n    singleQuote: true,\n    printWidth: 80,\n    sortPackageJson: false,\n    ignorePatterns: [],\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-pkg-json/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should detect prettier in package.json and auto-migrate\",\n    \"cat package.json # check prettier key removed, scripts rewritten, dep removed\",\n    \"cat vite.config.ts # check oxfmt config merged into vite.config.ts with semi/singleQuote settings\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-rerun/.prettierrc.json",
    "content": "{\n  \"semi\": true,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-rerun/package.json",
    "content": "{\n  \"name\": \"migration-prettier-rerun\",\n  \"scripts\": {\n    \"format\": \"prettier --write .\"\n  },\n  \"devDependencies\": {\n    \"prettier\": \"^3.0.0\",\n    \"vite-plus\": \"latest\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-rerun/snap.txt",
    "content": "> vp migrate --no-interactive # should detect vite-plus + prettier and auto-migrate prettier\nVITE+ - The Unified Toolchain for the Web\n\n\nPrettier configuration detected. Auto-migrating to Oxfmt...\n\nMigrating Prettier config to Oxfmt...\n\nPrettier config migrated to .oxfmtrc.json\n\n✔ Removed .prettierrc.json\n◇ Updated .\n• Node <semver>  unknown latest\n• Prettier migrated to Oxfmt\n\n> cat package.json # check prettier removed from devDependencies and scripts rewritten\n{\n  \"name\": \"migration-prettier-rerun\",\n  \"scripts\": {\n    \"format\": \"vp fmt .\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  }\n}\n\n> cat .prettierrc.json && exit 1 || true # check prettier config is removed\ncat: .prettierrc.json: No such file or directory\n\n> cat vite.config.ts # check oxfmt config merged into vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  fmt: {\n    semi: true,\n    singleQuote: true,\n    printWidth: 80,\n    sortPackageJson: false,\n    ignorePatterns: [],\n  },\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-prettier-rerun/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # should detect vite-plus + prettier and auto-migrate prettier\",\n    \"cat package.json # check prettier removed from devDependencies and scripts rewritten\",\n    \"cat .prettierrc.json && exit 1 || true # check prettier config is removed\",\n    \"cat vite.config.ts # check oxfmt config merged into vite.config.ts\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-declare-module/package.json",
    "content": "{\n  \"name\": \"migration-rewrite-declare-module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite imports to vite-plus\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat src/index.ts # check src/index.ts\nimport type { RuntimeEnvConfig } from './runtime.env.config.js';\nimport type { RuntimeHtmlConfig } from './runtime.html.config.js';\n\ndeclare module 'vite-plus' {\n  interface UserConfig {\n    /**\n     * Options for vite-plugin-runtime-env\n     */\n    runtimeEnv?: RuntimeEnvConfig;\n    /**\n     * Options for vite-plugin-runtime-html\n     */\n    runtimeHtml?: RuntimeHtmlConfig;\n  }\n}\n\ndeclare module 'vite-plus/test' {\n  export const describe: any;\n  export const it: any;\n  export const expect: any;\n  export const beforeAll: any;\n  export const afterAll: any;\n}\n\ndeclare module 'vite-plus' {\n  export function defineConfig(config: any): any;\n  const _default: typeof defineConfig;\n  export default _default;\n}\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-rewrite-declare-module\",\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-declare-module/src/index.ts",
    "content": "import type { RuntimeEnvConfig } from './runtime.env.config.js';\nimport type { RuntimeHtmlConfig } from './runtime.html.config.js';\n\ndeclare module 'vite' {\n  interface UserConfig {\n    /**\n     * Options for vite-plugin-runtime-env\n     */\n    runtimeEnv?: RuntimeEnvConfig;\n    /**\n     * Options for vite-plugin-runtime-html\n     */\n    runtimeHtml?: RuntimeHtmlConfig;\n  }\n}\n\ndeclare module 'vitest' {\n  export const describe: any;\n  export const it: any;\n  export const expect: any;\n  export const beforeAll: any;\n  export const afterAll: any;\n}\n\ndeclare module 'vitest/config' {\n  export function defineConfig(config: any): any;\n  const _default: typeof defineConfig;\n  export default _default;\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-declare-module/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite imports to vite-plus\",\n    \"cat src/index.ts # check src/index.ts\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-reference-types/package.json",
    "content": "{\n  \"name\": \"migration-rewrite-reference-types\",\n  \"devDependencies\": {\n    \"vite\": \"^6.0.0\",\n    \"vitest\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-reference-types/snap.txt",
    "content": "> vp migrate --no-interactive # migration rewrites reference types\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat src/env.d.ts # check reference types rewritten\n/// <reference types=\"vite-plus\" />\n/// <reference types=\"vite-plus/client\" />\n/// <reference types=\"vite-plus/test\" />\n/// <reference types=\"vite-plus/test/globals\" />\n/// <reference types=\"vite-plus\" />\n/// <reference types=\"vite-plus/test/browser/context\" />\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-reference-types/src/env.d.ts",
    "content": "/// <reference types=\"vite\" />\n/// <reference types=\"vite/client\" />\n/// <reference types=\"vitest\" />\n/// <reference types=\"vitest/globals\" />\n/// <reference types=\"vitest/config\" />\n/// <reference types=\"@vitest/browser/context\" />\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-rewrite-reference-types/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration rewrites reference types\",\n    \"cat src/env.d.ts # check reference types rewritten\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-dependency/package.json",
    "content": "{\n  \"name\": \"migration-skip-vite-dependency\",\n  \"dependencies\": {\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt",
    "content": "> vp migrate --no-interactive # migration should skip rewriting vite imports when vite is in dependencies\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat src/index.ts # vite imports should NOT be rewritten, vitest imports SHOULD be rewritten\nimport { defineConfig, type Plugin } from 'vite';\nimport { describe, it, expect } from 'vite-plus/test';\n\nexport function myApp(): Plugin {\n  return {\n    name: 'my-app',\n    configResolved(config) {\n      console.log(config);\n    },\n  };\n}\n\ndescribe('myApp', () => {\n  it('should work', () => {\n    expect(myApp()).toBeDefined();\n  });\n});\n\nexport default defineConfig({\n  plugins: [myApp()],\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-skip-vite-dependency\",\n  \"dependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-dependency/src/index.ts",
    "content": "import { defineConfig, type Plugin } from 'vite';\nimport { describe, it, expect } from 'vitest';\n\nexport function myApp(): Plugin {\n  return {\n    name: 'my-app',\n    configResolved(config) {\n      console.log(config);\n    },\n  };\n}\n\ndescribe('myApp', () => {\n  it('should work', () => {\n    expect(myApp()).toBeDefined();\n  });\n});\n\nexport default defineConfig({\n  plugins: [myApp()],\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-dependency/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should skip rewriting vite imports when vite is in dependencies\",\n    \"cat src/index.ts # vite imports should NOT be rewritten, vitest imports SHOULD be rewritten\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/package.json",
    "content": "{\n  \"name\": \"migration-skip-vite-peer-dependency\",\n  \"peerDependencies\": {\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt",
    "content": "> vp migrate --no-interactive # migration should skip rewriting vite imports when vite is in peerDependencies\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied, 1 file had imports rewritten\n\n> cat src/index.ts # vite imports should NOT be rewritten, vitest imports SHOULD be rewritten\nimport { defineConfig, type Plugin } from 'vite';\nimport { describe, it, expect } from 'vite-plus/test';\n\nexport function myVitePlugin(): Plugin {\n  return {\n    name: 'my-vite-plugin',\n    configResolved(config) {\n      console.log(config);\n    },\n  };\n}\n\ndescribe('myVitePlugin', () => {\n  it('should work', () => {\n    expect(myVitePlugin()).toBeDefined();\n  });\n});\n\nexport default defineConfig({\n  plugins: [myVitePlugin()],\n});\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-skip-vite-peer-dependency\",\n  \"peerDependencies\": {\n    \"vite\": \"^6.0.0\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\",\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/src/index.ts",
    "content": "import { defineConfig, type Plugin } from 'vite';\nimport { describe, it, expect } from 'vitest';\n\nexport function myVitePlugin(): Plugin {\n  return {\n    name: 'my-vite-plugin',\n    configResolved(config) {\n      console.log(config);\n    },\n  };\n}\n\ndescribe('myVitePlugin', () => {\n  it('should work', () => {\n    expect(myVitePlugin()).toBeDefined();\n  });\n});\n\nexport default defineConfig({\n  plugins: [myVitePlugin()],\n});\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should skip rewriting vite imports when vite is in peerDependencies\",\n    \"cat src/index.ts # vite imports should NOT be rewritten, vitest imports SHOULD be rewritten\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-standalone-npm/.gitignore",
    "content": "node_modules/\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-standalone-npm/package.json",
    "content": "{\n  \"name\": \"migration-standalone-npm\",\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\",\n    \"vitest\": \"^4.0.0\"\n  },\n  \"packageManager\": \"npm@10.9.2\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-standalone-npm/snap.txt",
    "content": "> vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  npm <semver>\n✓ Dependencies installed in <variable>ms\n• 1 config update applied\n\n> cat package.json # check package.json has overrides field (not pnpm.overrides)\n{\n  \"name\": \"migration-standalone-npm\",\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"npm@<semver>\",\n  \"overrides\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  }\n}\n\n[1]> node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override\nvite override not found in lockfile\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-standalone-npm/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\",\n    \"CI\": \"\",\n    \"VITE_PLUS_SKIP_INSTALL\": \"\"\n  },\n  \"commands\": [\n    \"vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile\",\n    \"cat package.json # check package.json has overrides field (not pnpm.overrides)\",\n    \"node -e \\\"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && vite.resolved && vite.resolved.includes('@voidzero-dev/vite-plus-core')) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\\\" # verify lockfile updated with override\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-subpath/foo/package.json",
    "content": "{\n  \"name\": \"migration-subpath\",\n  \"lint-staged\": {\n    \"*.@(js|ts|tsx|yml|yaml|md|json|html|toml)\": [\n      \"oxfmt --staged\",\n      \"eslint --fix\"\n    ],\n    \"*.@(js|ts|tsx)\": [\n      \"oxlint --fix\"\n    ]\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-subpath/snap.txt",
    "content": "> git init\n> vp migrate foo --no-interactive # migration work with subpath\nVITE+ - The Unified Toolchain for the Web\n\n\n⚠ Subdirectory project detected — skipping git hooks setup. Configure hooks at the repository root.\n◇ Migrated foo to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 1 config update applied\n\n> cat foo/package.json # check package.json\n{\n  \"name\": \"migration-subpath\",\n  \"lint-staged\": {\n    \"*.@(js|ts|tsx|yml|yaml|md|json|html|toml)\": [\n      \"oxfmt --staged\",\n      \"eslint --fix\"\n    ],\n    \"*.@(js|ts|tsx)\": [\n      \"oxlint --fix\"\n    ]\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat foo/vite.config.ts # check vite.config.ts\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\"options\":{\"typeAware\":true,\"typeCheck\":true}},\n  \n});\n\n> git config --local core.hooksPath || echo 'core.hooksPath is not set' # should NOT be set\ncore.hooksPath is not set\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-subpath/steps.json",
    "content": "{\n  \"commands\": [\n    { \"command\": \"git init\", \"ignoreOutput\": true },\n    \"vp migrate foo --no-interactive # migration work with subpath\",\n    \"cat foo/package.json # check package.json\",\n    \"cat foo/vite.config.ts # check vite.config.ts\",\n    \"git config --local core.hooksPath || echo 'core.hooksPath is not set' # should NOT be set\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-vite-version/package.json",
    "content": "{\n  \"name\": \"migration-vite-version\",\n  \"scripts\": {\n    \"version\": \"vite --version\",\n    \"version:short\": \"vite -v\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^7.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-vite-version/snap.txt",
    "content": "> vp migrate --no-interactive # migration should rewrite vite --version to vp --version\nVITE+ - The Unified Toolchain for the Web\n\n◇ Migrated . to Vite+<repeat>\n• Node <semver>  pnpm <semver>\n• 2 config updates applied\n\n> cat package.json # check package.json\n{\n  \"name\": \"migration-vite-version\",\n  \"scripts\": {\n    \"version\": \"vp --version\",\n    \"version:short\": \"vp -v\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vite-plus\": \"latest\"\n  },\n  \"pnpm\": {\n    \"overrides\": {\n      \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n      \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n    }\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/migration-vite-version/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp migrate --no-interactive # migration should rewrite vite --version to vp --version\",\n    \"cat package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-check/snap.txt",
    "content": "> vp create --help # show help\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]\n\nUse any builtin, local or remote template with Vite+.\n\nArguments:\n  TEMPLATE  Template name. Run `vp create --list` to see available templates.\n            - Default: vite:monorepo, vite:application, vite:library, vite:generator\n            - Remote: vite, @tanstack/start, create-next-app,\n              create-nuxt, github:user/repo, https://github.com/user/template-repo, etc.\n            - Local: @company/generator-*, ./tools/create-ui-component\n\nOptions:\n  --directory DIR   Target directory for the generated project.\n  --agent NAME      Create an agent instructions file for the specified agent.\n  --editor NAME     Write editor config files for the specified editor.\n  --hooks           Set up pre-commit hooks (default in non-interactive mode)\n  --no-hooks        Skip pre-commit hooks setup\n  --verbose         Show detailed scaffolding output\n  --no-interactive  Run in non-interactive mode\n  --list            List all available templates\n  -h, --help        Show this help message\n\nTemplate Options:\n  Any arguments after -- are passed directly to the template.\n\nExamples:\n  # Interactive mode\n  vp create\n\n  # Use existing templates (shorthand expands to create-* packages)\n  vp create vite\n  vp create @tanstack/start\n  vp create svelte\n  vp create vite -- --template react-ts\n\n  # Full package names also work\n  vp create create-vite\n  vp create create-next-app\n\n  # Create Vite+ monorepo, application, library, or generator scaffolds\n  vp create vite:monorepo\n  vp create vite:application\n  vp create vite:library\n  vp create vite:generator\n\n  # Use templates from GitHub (via degit)\n  vp create github:user/repo\n  vp create https://github.com/user/template-repo\n\nDocumentation: https://viteplus.dev/guide/create\n\n\n> vp create --list # list templates\nVITE+ - The Unified Toolchain for the Web\n\nUsage: vp create --list\n\nList available builtin and popular project templates.\n\nVite+ Built-in Templates:\n  vite:monorepo     Create a new monorepo\n  vite:application  Create a new application\n  vite:library      Create a new library\n  vite:generator    Scaffold a new code generator (monorepo only)\n\nPopular Templates (shorthand):\n  vite             Official Vite templates (create-vite)\n  @tanstack/start  TanStack applications (@tanstack/create-start)\n  next-app         Next.js application (create-next-app)\n  nuxt             Nuxt application (create-nuxt)\n  react-router     React Router application (create-react-router)\n  svelte           Svelte application (sv create)\n  vue              Vue application (create-vue)\n\nExamples:\n  vp create # interactive mode\n  vp create vite # shorthand for create-vite\n  vp create @tanstack/start # shorthand for @tanstack/create-start\n  vp create <template> -- <options> # pass options to the template\n\nTip:\n  You can use any npm template or git repo with vp create.\n\nDocumentation: https://viteplus.dev/guide/create\n\n\n[1]> vp create --no-interactive # run in non-interactive mode without template name will show error\n\nA template name is required when running in non-interactive mode\n\nUsage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]\n\nExample:\n  # Create a new application in non-interactive mode with a custom target directory\n  vp create vite:application --no-interactive --directory=apps/my-app\n\nUse `vp create --list` to list all available templates, or run `vp create --help` for more information.\n\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-check/steps.json",
    "content": "{\n  \"commands\": [\n    \"vp create --help # show help\",\n    \"vp create --list # list templates\",\n    \"vp create --no-interactive # run in non-interactive mode without template name will show error\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-create-vite/snap.txt",
    "content": "> vp create vite:application --no-interactive # create vite app with default values\n> ls vite-plus-application/package.json # check package.json\nvite-plus-application/package.json\n\n> vp create vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template\n> ls my-react-ts/package.json # check package.json\nmy-react-ts/package.json\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-create-vite/steps.json",
    "content": "{\n  \"commands\": [\n    {\n      \"command\": \"vp create vite:application --no-interactive # create vite app with default values\",\n      \"ignoreOutput\": true\n    },\n    \"ls vite-plus-application/package.json # check package.json\",\n    {\n      \"command\": \"vp create vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template\",\n      \"ignoreOutput\": true\n    },\n    \"ls my-react-ts/package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-create-vite-directory-dot/snap.txt",
    "content": "> mkdir -p my-app && cd my-app && vp create vite:application --no-interactive --directory . # create vite app in current directory\n> test -f my-app/package.json && echo 'Created at my-app with --directory .' || echo 'NOT at my-app'\nCreated at my-app with --directory .\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-create-vite-directory-dot/steps.json",
    "content": "{\n  \"commands\": [\n    {\n      \"command\": \"mkdir -p my-app && cd my-app && vp create vite:application --no-interactive --directory . # create vite app in current directory\",\n      \"ignoreOutput\": true\n    },\n    \"test -f my-app/package.json && echo 'Created at my-app with --directory .' || echo 'NOT at my-app'\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-create-vite-with-scope-name/snap.txt",
    "content": "> vp create vite:application --no-interactive --directory @my-scope/my-vite-app -- --template vanilla-ts # create vite app with vanilla-ts template with scope name\n> ls my-vite-app/package.json # check package.json\nmy-vite-app/package.json\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-create-vite-with-scope-name/steps.json",
    "content": "{\n  \"commands\": [\n    {\n      \"command\": \"vp create vite:application --no-interactive --directory @my-scope/my-vite-app -- --template vanilla-ts # create vite app with vanilla-ts template with scope name\",\n      \"ignoreOutput\": true\n    },\n    \"ls my-vite-app/package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-vite-monorepo/snap.txt",
    "content": "> vp create vite:monorepo --no-interactive # create monorepo with default values\n> ls vite-plus-monorepo | LC_ALL=C sort # check files created\nAGENTS.md\nREADME.md\napps\npackage.json\npackages\npnpm-workspace.yaml\ntsconfig.json\nvite.config.ts\n\n> cat vite-plus-monorepo/package.json # check package.json\n{\n  \"name\": \"vite-plus-monorepo\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"ready\": \"vp fmt && vp lint && vp run test -r && vp run build -r\",\n    \"dev\": \"vp run website#dev\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"catalog:\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n\n> cat vite-plus-monorepo/pnpm-workspace.yaml # check workspace config\npackages:\n  - apps/*\n  - packages/*\n  - tools/*\n\ncatalogMode: prefer\n\ncatalog:\n  \"@types/node\": ^24\n  typescript: ^5\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n  vite-plus: latest\noverrides:\n  vite: \"catalog:\"\n  vitest: \"catalog:\"\npeerDependencyRules:\n  allowAny:\n    - vite\n    - vitest\n  allowedVersions:\n    vite: \"*\"\n    vitest: \"*\"\n\n> test -d vite-plus-monorepo/.git && echo 'Git initialized' || echo 'No git' # check git init\nGit initialized\n\n> ls vite-plus-monorepo/apps # check apps directory created\nwebsite\n\n> ls vite-plus-monorepo/apps/website/package.json # check website package.json\nvite-plus-monorepo/apps/website/package.json\n\n> cd vite-plus-monorepo && vp create --no-interactive vite:application # create application in non-interactive mode\n> ls vite-plus-monorepo/apps # check apps directory created\nvite-plus-application\nwebsite\n\n> ls vite-plus-monorepo/apps/vite-plus-application/package.json # check vite-plus-application package.json\nvite-plus-monorepo/apps/vite-plus-application/package.json\n\n> cd vite-plus-monorepo && vp create --no-interactive vite:library # create library in non-interactive mode\n> ls vite-plus-monorepo/packages/vite-plus-library/package.json # check vite-plus-library package.json\nvite-plus-monorepo/packages/vite-plus-library/package.json\n\n> cd vite-plus-monorepo && vp create --no-interactive vite:generator # create generator in non-interactive mode\n> ls vite-plus-monorepo/tools # check tools directory created\nvite-plus-generator\n\n> cat vite-plus-monorepo/tools/vite-plus-generator/package.json # check vite-plus-generator package.json\n{\n  \"name\": \"vite-plus-generator\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"description\": \"A starter for creating a Vite+ code generator.\",\n  \"keywords\": [\n    \"bingo-template\",\n    \"vite-plus-generator\"\n  ],\n  \"bin\": \"./bin/index.ts\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"test\": \"vp test\",\n    \"dev\": \"node bin/index.ts\"\n  },\n  \"dependencies\": {\n    \"bingo\": \"^0.7.0\",\n    \"zod\": \"^3.25.76\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  }\n}\n\n> vp create vite:monorepo --no-interactive --directory my-vite-plus-monorepo # create monorepo with custom directory\n> ls my-vite-plus-monorepo | LC_ALL=C sort # check files created\nAGENTS.md\nREADME.md\napps\npackage.json\npackages\npnpm-workspace.yaml\ntsconfig.json\nvite.config.ts\n\n> cat my-vite-plus-monorepo/package.json # check package.json\n{\n  \"name\": \"my-vite-plus-monorepo\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"ready\": \"vp fmt && vp lint && vp run test -r && vp run build -r\",\n    \"dev\": \"vp run website#dev\",\n    \"prepare\": \"vp config\"\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"catalog:\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  },\n  \"packageManager\": \"pnpm@<semver>\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/new-vite-monorepo/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    {\n      \"command\": \"vp create vite:monorepo --no-interactive # create monorepo with default values\",\n      \"ignoreOutput\": true\n    },\n    \"ls vite-plus-monorepo | LC_ALL=C sort # check files created\",\n    \"cat vite-plus-monorepo/package.json # check package.json\",\n    \"cat vite-plus-monorepo/pnpm-workspace.yaml # check workspace config\",\n    \"test -d vite-plus-monorepo/.git && echo 'Git initialized' || echo 'No git' # check git init\",\n    \"ls vite-plus-monorepo/apps # check apps directory created\",\n    \"ls vite-plus-monorepo/apps/website/package.json # check website package.json\",\n    {\n      \"command\": \"cd vite-plus-monorepo && vp create --no-interactive vite:application # create application in non-interactive mode\",\n      \"ignoreOutput\": true\n    },\n    \"ls vite-plus-monorepo/apps # check apps directory created\",\n    \"ls vite-plus-monorepo/apps/vite-plus-application/package.json # check vite-plus-application package.json\",\n    {\n      \"command\": \"cd vite-plus-monorepo && vp create --no-interactive vite:library # create library in non-interactive mode\",\n      \"ignoreOutput\": true\n    },\n    \"ls vite-plus-monorepo/packages/vite-plus-library/package.json # check vite-plus-library package.json\",\n    {\n      \"command\": \"cd vite-plus-monorepo && vp create --no-interactive vite:generator # create generator in non-interactive mode\",\n      \"ignoreOutput\": true\n    },\n    \"ls vite-plus-monorepo/tools # check tools directory created\",\n    \"cat vite-plus-monorepo/tools/vite-plus-generator/package.json # check vite-plus-generator package.json\",\n    {\n      \"command\": \"vp create vite:monorepo --no-interactive --directory my-vite-plus-monorepo # create monorepo with custom directory\",\n      \"ignoreOutput\": true\n    },\n    \"ls my-vite-plus-monorepo | LC_ALL=C sort # check files created\",\n    \"cat my-vite-plus-monorepo/package.json # check package.json\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-already-linked/.node-version",
    "content": "22.22.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-already-linked/npm-global-linked-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-linked-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-already-linked/npm-global-linked-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-linked-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-linked-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-already-linked/snap.txt",
    "content": "> npm config get prefix\n<cwd>/../npm-global-lib-for-snap-tests\n\n> vp install -g ./npm-global-linked-pkg # First install via vp (creates managed shim)\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./npm-global-linked-pkg globally...\nInstalled ./npm-global-linked-pkg v<semver>\nBinaries: npm-global-linked-cli\n\n> npm-global-linked-cli # Should be callable via the link\nnpm-global-linked-cli works\n\n> npm install -g ./npm-global-linked-pkg # Should NOT show hint (binary already exists)\n\nadded 1 package in <variable>ms\nSkipped 'npm-global-linked-cli': managed by `vp install -g ./npm-global-linked-pkg`. Run `vp uninstall -g ./npm-global-linked-pkg` to remove it first.\n\n> vp remove -g npm-global-linked-pkg # Cleanup\nUninstalled npm-global-linked-pkg\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-linked-cli && echo 'link created' || echo 'link removed'\nlink removed\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-already-linked/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"commands\": [\n    \"npm config get prefix\",\n    \"vp install -g ./npm-global-linked-pkg # First install via vp (creates managed shim)\",\n    \"npm-global-linked-cli # Should be callable via the link\",\n    \"npm install -g ./npm-global-linked-pkg # Should NOT show hint (binary already exists)\",\n    \"vp remove -g npm-global-linked-pkg # Cleanup\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-linked-cli && echo 'link created' || echo 'link removed'\"\n  ],\n  \"after\": [\"npm uninstall -g npm-global-linked-pkg\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix/npm-global-custom-prefix-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-custom-prefix-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix/npm-global-custom-prefix-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-custom-prefix-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-custom-prefix-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix/snap.txt",
    "content": "> mkdir -p custom-prefix\n> NPM_CONFIG_PREFIX=$(pwd)/custom-prefix npm install -g ./npm-global-custom-prefix-pkg # Should install to custom prefix and show hint\n\nadded 1 package in <variable>ms\nLinked 'npm-global-custom-prefix-cli' to <vite-plus-home>/bin/npm-global-custom-prefix-cli\n\ntip: Use `vp install -g ./npm-global-custom-prefix-pkg` for managed shims that persist across Node.js version changes.\n\n> test -f custom-prefix/bin/npm-global-custom-prefix-cli && echo 'binary in custom prefix' # Verify installed to custom prefix\nbinary in custom prefix\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-custom-prefix-cli && echo 'link created' # Link should exist\nlink created\n\n> npm-global-custom-prefix-cli # Should be callable via the link\nnpm-global-custom-prefix-cli works\n\n> rm -f $VITE_PLUS_HOME/bin/npm-global-custom-prefix-cli # Cleanup link\n> NPM_CONFIG_PREFIX=$(pwd)/custom-prefix npm uninstall -g npm-global-custom-prefix-pkg; rm -rf custom-prefix # Cleanup\n\nremoved 1 package in <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p custom-prefix\",\n    \"NPM_CONFIG_PREFIX=$(pwd)/custom-prefix npm install -g ./npm-global-custom-prefix-pkg # Should install to custom prefix and show hint\",\n    \"test -f custom-prefix/bin/npm-global-custom-prefix-cli && echo 'binary in custom prefix' # Verify installed to custom prefix\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-custom-prefix-cli && echo 'link created' # Link should exist\",\n    \"npm-global-custom-prefix-cli # Should be callable via the link\",\n    \"rm -f $VITE_PLUS_HOME/bin/npm-global-custom-prefix-cli # Cleanup link\",\n    \"NPM_CONFIG_PREFIX=$(pwd)/custom-prefix npm uninstall -g npm-global-custom-prefix-pkg; rm -rf custom-prefix # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix-on-path/npm-global-on-path-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-on-path-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix-on-path/npm-global-on-path-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-on-path-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-on-path-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix-on-path/snap.txt",
    "content": "> mkdir -p custom-prefix-on-path/bin\n> PATH=$(pwd)/custom-prefix-on-path/bin:$PATH NPM_CONFIG_PREFIX=$(pwd)/custom-prefix-on-path npm install -g ./npm-global-on-path-pkg # Should install without hint (bin dir on PATH)\n\nadded 1 package in <variable>ms\n\n> test -f custom-prefix-on-path/bin/npm-global-on-path-cli && echo 'binary in custom prefix' # Verify installed to custom prefix\nbinary in custom prefix\n\n> test ! -L $VITE_PLUS_HOME/bin/npm-global-on-path-cli && echo 'no link created' # No link should be created\nno link created\n\n> NPM_CONFIG_PREFIX=$(pwd)/custom-prefix-on-path npm uninstall -g npm-global-on-path-pkg; rm -rf custom-prefix-on-path # Cleanup\n\nremoved 1 package in <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-custom-prefix-on-path/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p custom-prefix-on-path/bin\",\n    \"PATH=$(pwd)/custom-prefix-on-path/bin:$PATH NPM_CONFIG_PREFIX=$(pwd)/custom-prefix-on-path npm install -g ./npm-global-on-path-pkg # Should install without hint (bin dir on PATH)\",\n    \"test -f custom-prefix-on-path/bin/npm-global-on-path-cli && echo 'binary in custom prefix' # Verify installed to custom prefix\",\n    \"test ! -L $VITE_PLUS_HOME/bin/npm-global-on-path-cli && echo 'no link created' # No link should be created\",\n    \"NPM_CONFIG_PREFIX=$(pwd)/custom-prefix-on-path npm uninstall -g npm-global-on-path-pkg; rm -rf custom-prefix-on-path # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-dot/npm-global-dot-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-dot-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-dot/npm-global-dot-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-dot-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-dot-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-dot/snap.txt",
    "content": "> cd npm-global-dot-pkg && npm install -g . # Should install and show hint + create link\n\nadded 1 package in <variable>ms\nLinked 'npm-global-dot-cli' to <vite-plus-home>/bin/npm-global-dot-cli\n\ntip: Use `vp install -g .` for managed shims that persist across Node.js version changes.\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-dot-cli && echo 'link created' # Link should exist\nlink created\n\n> npm-global-dot-cli # Should be callable via the link\nnpm-global-dot-cli works\n\n> rm -f $VITE_PLUS_HOME/bin/npm-global-dot-cli # Cleanup link\n> npm uninstall -g npm-global-dot-pkg # Cleanup npm install\n\nremoved 1 package in <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-dot/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"cd npm-global-dot-pkg && npm install -g . # Should install and show hint + create link\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-dot-cli && echo 'link created' # Link should exist\",\n    \"npm-global-dot-cli # Should be callable via the link\",\n    \"rm -f $VITE_PLUS_HOME/bin/npm-global-dot-cli # Cleanup link\",\n    \"npm uninstall -g npm-global-dot-pkg # Cleanup npm install\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-hint/npm-global-hint-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-hint-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-hint/npm-global-hint-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-hint-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-hint-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-hint/snap.txt",
    "content": "> npm install -g ./npm-global-hint-pkg # Should install and show hint + create link\n\nadded 1 package in <variable>ms\nLinked 'npm-global-hint-cli' to <vite-plus-home>/bin/npm-global-hint-cli\n\ntip: Use `vp install -g ./npm-global-hint-pkg` for managed shims that persist across Node.js version changes.\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-hint-cli && echo 'link created' # Link should exist\nlink created\n\n> npm-global-hint-cli # Should be callable via the link\nnpm-global-hint-cli works\n\n> rm -f $VITE_PLUS_HOME/bin/npm-global-hint-cli # Cleanup link\n> npm uninstall -g npm-global-hint-pkg # Cleanup npm install\n\nremoved 1 package in <variable>ms\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-install-hint/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"npm install -g ./npm-global-hint-pkg # Should install and show hint + create link\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-hint-cli && echo 'link created' # Link should exist\",\n    \"npm-global-hint-cli # Should be callable via the link\",\n    \"rm -f $VITE_PLUS_HOME/bin/npm-global-hint-cli # Cleanup link\",\n    \"npm uninstall -g npm-global-hint-pkg # Cleanup npm install\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-link-cleanup/npm-global-uninstall-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-uninstall-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-link-cleanup/npm-global-uninstall-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-uninstall-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-uninstall-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-link-cleanup/snap.txt",
    "content": "> npm install -g ./npm-global-uninstall-pkg # Install package first\n\nadded 1 package in <variable>ms\nLinked 'npm-global-uninstall-cli' to <vite-plus-home>/bin/npm-global-uninstall-cli\n\ntip: Use `vp install -g ./npm-global-uninstall-pkg` for managed shims that persist across Node.js version changes.\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-uninstall-cli && echo 'link exists' # Link should exist after install\nlink exists\n\n> npm uninstall -g npm-global-uninstall-pkg # Uninstall should remove the link\n\nremoved 1 package in <variable>ms\nRemoved link 'npm-global-uninstall-cli' from <vite-plus-home>/bin/npm-global-uninstall-cli\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-uninstall-cli && echo 'link exists' || echo 'link removed' # Link should be gone\nlink removed\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-link-cleanup/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"npm install -g ./npm-global-uninstall-pkg # Install package first\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-uninstall-cli && echo 'link exists' # Link should exist after install\",\n    \"npm uninstall -g npm-global-uninstall-pkg # Uninstall should remove the link\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-uninstall-cli && echo 'link exists' || echo 'link removed' # Link should be gone\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-preexisting-binary/npm-global-preexist-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-preexist-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-preexisting-binary/npm-global-preexist-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-preexist-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-preexist-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-preexisting-binary/snap.txt",
    "content": "> printf '#!/bin/sh\\necho preexisting-binary-works' > $VITE_PLUS_HOME/bin/npm-global-preexist-cli && chmod +x $VITE_PLUS_HOME/bin/npm-global-preexist-cli # Create user-owned binary\n> npm-global-preexist-cli # Verify it works before\npreexisting-binary-works\n\n> npm install -g ./npm-global-preexist-pkg # Install pkg that declares same bin name\n\nadded 1 package in <variable>ms\n\n> npm uninstall -g npm-global-preexist-pkg # Should NOT remove the pre-existing binary\n\nremoved 1 package in <variable>ms\n\n> npm-global-preexist-cli # Should still work\npreexisting-binary-works\n\n> rm $VITE_PLUS_HOME/bin/npm-global-preexist-cli # Cleanup"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-preexisting-binary/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"printf '#!/bin/sh\\\\necho preexisting-binary-works' > $VITE_PLUS_HOME/bin/npm-global-preexist-cli && chmod +x $VITE_PLUS_HOME/bin/npm-global-preexist-cli # Create user-owned binary\",\n    \"npm-global-preexist-cli # Verify it works before\",\n    \"npm install -g ./npm-global-preexist-pkg # Install pkg that declares same bin name\",\n    \"npm uninstall -g npm-global-preexist-pkg # Should NOT remove the pre-existing binary\",\n    \"npm-global-preexist-cli # Should still work\",\n    \"rm $VITE_PLUS_HOME/bin/npm-global-preexist-cli # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-prefix/npm-global-prefix-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-prefix-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-prefix/npm-global-prefix-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-prefix-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-prefix-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-prefix/snap.txt",
    "content": "> mkdir -p custom-prefix\n> npm install -g --prefix $(pwd)/custom-prefix ./npm-global-prefix-pkg # Install to custom prefix, should create link\n\nadded 1 package in <variable>ms\nLinked 'npm-global-prefix-cli' to <vite-plus-home>/bin/npm-global-prefix-cli\n\ntip: Use `vp install -g ./npm-global-prefix-pkg` for managed shims that persist across Node.js version changes.\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-prefix-cli && echo 'link exists' # Link should exist\nlink exists\n\n> npm-global-prefix-cli # Verify callable via link\nnpm-global-prefix-cli works\n\n> npm uninstall -g --prefix $(pwd)/custom-prefix npm-global-prefix-pkg # Uninstall should also remove link\n\nremoved 1 package in <variable>ms\nRemoved link 'npm-global-prefix-cli' from <vite-plus-home>/bin/npm-global-prefix-cli\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-prefix-cli && echo 'link exists' || echo 'link removed' # Should be gone\nlink removed\n\n> rm -rf custom-prefix # Cleanup"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-prefix/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"mkdir -p custom-prefix\",\n    \"npm install -g --prefix $(pwd)/custom-prefix ./npm-global-prefix-pkg # Install to custom prefix, should create link\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-prefix-cli && echo 'link exists' # Link should exist\",\n    \"npm-global-prefix-cli # Verify callable via link\",\n    \"npm uninstall -g --prefix $(pwd)/custom-prefix npm-global-prefix-pkg # Uninstall should also remove link\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-prefix-cli && echo 'link exists' || echo 'link removed' # Should be gone\",\n    \"rm -rf custom-prefix # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-shared-bin-name/pkg-a/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('shared-cli from pkg-a');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-shared-bin-name/pkg-a/package.json",
    "content": "{\n  \"name\": \"npm-global-shared-pkg-a\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-shared-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-shared-bin-name/pkg-b/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('shared-cli from pkg-b');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-shared-bin-name/pkg-b/package.json",
    "content": "{\n  \"name\": \"npm-global-shared-pkg-b\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-shared-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-shared-bin-name/snap.txt",
    "content": "> npm install -g ./pkg-a # Install pkg-a (creates link for npm-global-shared-cli)\n\nadded 1 package in <variable>ms\nLinked 'npm-global-shared-cli' to <vite-plus-home>/bin/npm-global-shared-cli\n\ntip: Use `vp install -g ./pkg-a` for managed shims that persist across Node.js version changes.\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists'\nlink exists\n\n> cat $VITE_PLUS_HOME/bins/npm-global-shared-cli.json # BinConfig should point to pkg-a\n{\n  \"name\": \"npm-global-shared-cli\",\n  \"package\": \"npm-global-shared-pkg-a\",\n  \"version\": \"\",\n  \"nodeVersion\": \"24.14.0\",\n  \"source\": \"npm\"\n}\n> npm install -g --force ./pkg-b # Install pkg-b with force (overwrites npm-global-shared-cli)\nnpm warn using --force Recommended protections disabled.\n\nadded 1 package in <variable>ms\nLinked 'npm-global-shared-cli' to <vite-plus-home>/bin/npm-global-shared-cli\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists'\nlink exists\n\n> cat $VITE_PLUS_HOME/bins/npm-global-shared-cli.json # BinConfig should now point to pkg-b\n{\n  \"name\": \"npm-global-shared-cli\",\n  \"package\": \"npm-global-shared-pkg-b\",\n  \"version\": \"\",\n  \"nodeVersion\": \"24.14.0\",\n  \"source\": \"npm\"\n}\n> npm-global-shared-cli # Should print pkg-b message (latest installed)\nshared-cli from pkg-b\n\n> npm uninstall -g npm-global-shared-pkg-a # Uninstall pkg-a, should NOT remove the link (owned by pkg-b now)\n\nremoved 1 package in <variable>ms\nLinked 'npm-global-shared-cli' to <vite-plus-home>/bin/npm-global-shared-cli\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists' || echo 'link removed'\nlink exists\n\n> npm-global-shared-cli # Should still work (owned by pkg-b)\nshared-cli from pkg-b\n\n> npm uninstall -g npm-global-shared-pkg-b # Uninstall pkg-b, NOW should remove the link\n\nremoved 1 package in <variable>ms\nRemoved link 'npm-global-shared-cli' from <vite-plus-home>/bin/npm-global-shared-cli\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists' || echo 'link removed'\nlink removed\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-shared-bin-name/steps.json",
    "content": "{\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"npm install -g ./pkg-a # Install pkg-a (creates link for npm-global-shared-cli)\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists'\",\n    \"cat $VITE_PLUS_HOME/bins/npm-global-shared-cli.json # BinConfig should point to pkg-a\",\n    \"npm install -g --force ./pkg-b # Install pkg-b with force (overwrites npm-global-shared-cli)\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists'\",\n    \"cat $VITE_PLUS_HOME/bins/npm-global-shared-cli.json # BinConfig should now point to pkg-b\",\n    \"npm-global-shared-cli # Should print pkg-b message (latest installed)\",\n    \"npm uninstall -g npm-global-shared-pkg-a # Uninstall pkg-a, should NOT remove the link (owned by pkg-b now)\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists' || echo 'link removed'\",\n    \"npm-global-shared-cli # Should still work (owned by pkg-b)\",\n    \"npm uninstall -g npm-global-shared-pkg-b # Uninstall pkg-b, NOW should remove the link\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-shared-cli && echo 'link exists' || echo 'link removed'\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/npm-global-vp-managed-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconsole.log('npm-global-vp-managed-cli works');\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/npm-global-vp-managed-pkg/package.json",
    "content": "{\n  \"name\": \"npm-global-vp-managed-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"npm-global-vp-managed-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/snap.txt",
    "content": "> vp install -g ./npm-global-vp-managed-pkg # Install via vp (creates managed shim)\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./npm-global-vp-managed-pkg globally...\nInstalled ./npm-global-vp-managed-pkg v<semver>\nBinaries: npm-global-vp-managed-cli\n\n> npm install -g ./npm-global-vp-managed-pkg # npm install (should warn about conflict)\n\nadded 1 package in <variable>ms\nSkipped 'npm-global-vp-managed-cli': managed by `vp install -g ./npm-global-vp-managed-pkg`. Run `vp uninstall -g ./npm-global-vp-managed-pkg` to remove it first.\n\n> npm uninstall -g npm-global-vp-managed-pkg # npm uninstall should NOT remove the vp-managed shim\n\nremoved 1 package in <variable>ms\n\n> test -L $VITE_PLUS_HOME/bin/npm-global-vp-managed-cli && echo 'link exists' || echo 'link removed' # Shim should still exist\nlink exists\n\n> npm-global-vp-managed-cli # Verify the shim still works\nnpm-global-vp-managed-cli works\n\n> vp remove -g npm-global-vp-managed-pkg # Cleanup\nUninstalled npm-global-vp-managed-pkg\n"
  },
  {
    "path": "packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/steps.json",
    "content": "{\n  \"env\": {},\n  \"serial\": true,\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install -g ./npm-global-vp-managed-pkg # Install via vp (creates managed shim)\",\n    \"npm install -g ./npm-global-vp-managed-pkg # npm install (should warn about conflict)\",\n    \"npm uninstall -g npm-global-vp-managed-pkg # npm uninstall should NOT remove the vp-managed shim\",\n    \"test -L $VITE_PLUS_HOME/bin/npm-global-vp-managed-cli && echo 'link exists' || echo 'link removed' # Shim should still exist\",\n    \"npm-global-vp-managed-cli # Verify the shim still works\",\n    \"vp remove -g npm-global-vp-managed-pkg # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-dev-engines-runtime/package.json",
    "content": "{\n  \"name\": \"shim-inherits-parent-dev-engines-runtime\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"20.18.0\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-dev-engines-runtime/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-dev-engines-runtime/snap.txt",
    "content": "> vp env exec node -v # Root: uses devEngines.runtime directly\nv20.18.0\n\n> cd packages/app && vp env exec node -v # Sub-package: inherits parent devEngines.runtime\nv20.18.0\n\n> test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package\nNo .node-version written in sub-package\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-dev-engines-runtime/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env exec node -v # Root: uses devEngines.runtime directly\",\n    \"cd packages/app && vp env exec node -v # Sub-package: inherits parent devEngines.runtime\",\n    \"test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-engines-node/package.json",
    "content": "{\n  \"name\": \"shim-inherits-parent-engines-node\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \"20.18.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-engines-node/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-engines-node/snap.txt",
    "content": "> vp env exec node -v # Root: uses engines.node directly\nv20.18.0\n\n> cd packages/app && vp env exec node -v # Sub-package: inherits parent engines.node\nv20.18.0\n\n> test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package\nNo .node-version written in sub-package\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-engines-node/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env exec node -v # Root: uses engines.node directly\",\n    \"cd packages/app && vp env exec node -v # Sub-package: inherits parent engines.node\",\n    \"test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-node-version/.node-version",
    "content": "20.18.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-node-version/package.json",
    "content": "{\n  \"name\": \"shim-inherits-parent-node-version\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-node-version/packages/app/package.json",
    "content": "{\n  \"name\": \"app\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-node-version/snap.txt",
    "content": "> vp env exec node -v # Root: uses .node-version directly\nv20.18.0\n\n> cd packages/app && vp env exec node -v # Sub-package: inherits parent .node-version\nv20.18.0\n\n> test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package\nNo .node-version written in sub-package\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-inherits-parent-node-version/steps.json",
    "content": "{\n  \"env\": {},\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp env exec node -v # Root: uses .node-version directly\",\n    \"cd packages/app && vp env exec node -v # Sub-package: inherits parent .node-version\",\n    \"test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-pnpm-uses-project-node-version/.node-version",
    "content": "22.12.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-pnpm-uses-project-node-version/package.json",
    "content": "{\n  \"name\": \"shim-pnpm-uses-project-node-version\",\n  \"version\": \"1.0.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-pnpm-uses-project-node-version/snap.txt",
    "content": "> vp install -g pnpm > /dev/null # Ensure pnpm is globally installed\n> vp env exec node -v # Node version resolved from .node-version\nv22.12.0\n\n> vp env exec pnpm exec node -v # pnpm should use same project Node version\nv22.12.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-pnpm-uses-project-node-version/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {},\n  \"commands\": [\n    \"vp install -g pnpm > /dev/null # Ensure pnpm is globally installed\",\n    \"vp env exec node -v # Node version resolved from .node-version\",\n    \"vp env exec pnpm exec node -v # pnpm should use same project Node version\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-npm-run/package.json",
    "content": "{\n  \"name\": \"shim-recursive-npm-run\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"outer\": \"npm run inner\",\n    \"inner\": \"echo hello from inner\"\n  },\n  \"engines\": {\n    \"node\": \"22.12.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-npm-run/snap.txt",
    "content": "> npm run outer # Outer script calls npm run inner recursively\n\n> shim-recursive-npm-run@<semver> outer\n> npm run inner\n\n\n> shim-recursive-npm-run@<semver> inner\n> echo hello from inner\n\nhello from inner\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-npm-run/steps.json",
    "content": "{\n  \"env\": {},\n  \"commands\": [\"npm run outer # Outer script calls npm run inner recursively\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-package-binary/.node-version",
    "content": "22.12.0\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-package-binary/recursive-cli-pkg/cli.js",
    "content": "#!/usr/bin/env node\nconst args = process.argv.slice(2);\nif (args[0] === 'inner') {\n  console.log('inner call succeeded');\n} else {\n  console.log('outer call');\n  const { execSync } = require('child_process');\n  // This re-invokes the shim, testing recursion\n  const output = execSync('recursive-cli inner', { encoding: 'utf8' });\n  process.stdout.write(output);\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-package-binary/recursive-cli-pkg/package.json",
    "content": "{\n  \"name\": \"recursive-cli-pkg\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"recursive-cli\": \"./cli.js\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-package-binary/snap.txt",
    "content": "> vp install -g ./recursive-cli-pkg # Install test package\nVITE+ - The Unified Toolchain for the Web\n\nInstalling ./recursive-cli-pkg globally...\nInstalled ./recursive-cli-pkg v<semver>\nBinaries: recursive-cli\n\n> recursive-cli # Outer call triggers recursive inner call through shim\nouter call\ninner call succeeded\n\n> vp remove -g recursive-cli-pkg # Cleanup\nUninstalled recursive-cli-pkg\n"
  },
  {
    "path": "packages/cli/snap-tests-global/shim-recursive-package-binary/steps.json",
    "content": "{\n  \"env\": {},\n  \"commands\": [\n    \"vp install -g ./recursive-cli-pkg # Install test package\",\n    \"recursive-cli # Outer call triggers recursive inner call through shim\",\n    \"vp remove -g recursive-cli-pkg # Cleanup\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/kill-watch.sh",
    "content": "#!/bin/bash\n# Kill children processes first, then the parent\nif [ -f .pid ]; then\n  PID=$(cat .pid)\n  # Kill all child processes of the parent PID\n  pkill -P $PID 2>/dev/null || true\n  # Then kill the parent process\n  kill $PID 2>/dev/null || true\nfi\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/package.json",
    "content": "{\n  \"name\": \"command-pack-watch-restart\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/run-watch.sh",
    "content": "#!/bin/bash\n# Start vp pack watch in background, redirect output to file, and save PID\nvp pack --watch > .watch-output.log 2>&1 &\necho $! > .pid\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/snap.txt",
    "content": "> bash run-watch.sh\n> bash wait-for-dist.sh # wait for initial build\ndist found\n\n> ls dist # should have initial build\nindex.mjs\n\n> sed -i.bak \"s/outDir: 'dist'/outDir: 'dist2'/\" vite.config.ts\n> bash wait-for-dist2.sh # wait for rebuild with new config\ndist2 found\n\n> ls dist2 # should have rebuild with new outDir after config change\nindex.mjs\n\n> grep 'Reload config' .watch-output.log # verify restart was triggered\nℹ Reload config: <cwd>/vite.config.ts, restarting...\nℹ Reload config: <cwd>/vite.config.ts, restarting...\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/src/index.ts",
    "content": "export function hello() {\n  console.log('Hello!');\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"bash run-watch.sh\",\n    \"bash wait-for-dist.sh # wait for initial build\",\n    \"ls dist # should have initial build\",\n    \"sed -i.bak \\\"s/outDir: 'dist'/outDir: 'dist2'/\\\" vite.config.ts\",\n    {\n      \"command\": \"bash wait-for-dist2.sh # wait for rebuild with new config\",\n      \"timeout\": 50000\n    },\n    \"ls dist2 # should have rebuild with new outDir after config change\",\n    \"grep 'Reload config' .watch-output.log # verify restart was triggered\"\n  ],\n  \"after\": [\"bash kill-watch.sh\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/vite.config.ts",
    "content": "export default {\n  pack: {\n    entry: 'src/index.ts',\n    outDir: 'dist',\n  },\n};\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/wait-for-dist.sh",
    "content": "#!/bin/bash\n# Wait for dist directory to appear (max 10 seconds)\nfor i in {1..20}; do\n  if [ -d \"dist\" ]; then\n    echo \"dist found\"\n    exit 0\n  fi\n  sleep 0.5\ndone\necho \"dist not found after 10 seconds\"\nexit 1\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/command-pack-watch-restart/wait-for-dist2.sh",
    "content": "#!/bin/bash\n# Wait for dist2 directory to appear (max 40 seconds)\n# Rolldown notify polling interval default is 30s\n# https://github.com/rolldown/rolldown/blob/097316fb273a697d59b5b6f40d0cb30f30eb4296/packages/rolldown/src/options/input-options.ts#L78\nfor i in {1..80}; do\n  if [ -d \"dist2\" ]; then\n    echo \"dist2 found\"\n    exit 0\n  fi\n  sleep 0.5\ndone\necho \"dist2 not found after 40 seconds\"\necho \"=== .watch-output.log ===\"\ncat .watch-output.log 2>/dev/null || echo \"(no log file)\"\nexit 1\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/exit-non-zero-on-cmd-not-exists/package.json",
    "content": "{}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/exit-non-zero-on-cmd-not-exists/snap.txt",
    "content": "[2]> vite command-not-exists # should exit with non-zero code\nerror: 'vite' requires a subcommand but one was not provided\n  [subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, dedupe, outdated, why, explain, link, ln, unlink, pm, help]\n\nUsage: vite [OPTIONS] [TASK] [-- <TASK_ARGS>...] <COMMAND>\n\nFor more information, try '--help'.\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/exit-non-zero-on-cmd-not-exists/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vite command-not-exists # should exit with non-zero code\"]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/pnpm-install-with-options/package.json",
    "content": "{\n  \"name\": \"pnpm-install-with-options\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"tslib\": \"2.8.1\"\n  },\n  \"devDependencies\": {\n    \"oxlint\": \"latest\"\n  },\n  \"packageManager\": \"pnpm@10.15.1\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/pnpm-install-with-options/snap.txt",
    "content": "> vp install --help | head -n 20 # print help message\nVersion <semver> (compiled to binary; bundled Node.js v<semver>)\nUsage: pnpm install [options]\n\nAlias: i\n\nInstalls all dependencies of the project in the current working directory. When executed inside a workspace, installs all dependencies of all projects.\n\nOptions:\n      --[no-]color                      Controls colors in the output. By\n                                        default, output is always colored when\n                                        it goes directly to a terminal\n      --[no-]frozen-lockfile            Don't generate a lockfile and fail if an\n                                        update is needed. This setting is on by\n                                        default in CI environments, so use\n                                        --no-frozen-lockfile if you need to\n                                        disable it for some reason\n      --[no-]verify-store-integrity     If false, doesn't check whether packages\n                                        in the store were mutated\n      --aggregate-output                Aggregate output from child processes\n\n> vp install --prod # https://pnpm.io/cli/install#--prod--p\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ tslib <semver>\n\ndevDependencies: skipped\n\nDone in <variable>ms using pnpm v<semver>\n\n\n> ls node_modules\ntslib\n\n> vp install --prod # install again hit cache\n✓ cache hit, replaying\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndependencies:\n+ tslib <semver>\n\ndevDependencies: skipped\n\nDone in <variable>ms using pnpm v<semver>\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/pnpm-install-with-options/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\n    \"vp install --help | head -n 20 # print help message\",\n    \"vp install --prod # https://pnpm.io/cli/install#--prod--p\",\n    \"ls node_modules\",\n    \"vp install --prod # install again hit cache\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/test-panicked-fix/package.json",
    "content": "{\n  \"name\": \"test-panicked-fix\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@10.17.0\"\n}\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/test-panicked-fix/snap.txt",
    "content": "> vp lint --help | head -n 20 # print help message and no panicked\nUsage: [-c=<./.oxlintrc.json>] [PATH]...\n\nBasic Configuration\n    -c, --config=<./.oxlintrc.json>  Oxlint configuration file (experimental)\n                              * only `.json` extension is supported\n                              * you can use comments in configuration files.\n                              * tries to be compatible with the ESLint v8's format\n        --tsconfig=<./tsconfig.json>  TypeScript `tsconfig.json` path for reading path alias and\n                              project references for import plugin\n        --init                Initialize oxlint configuration with default values\n\nAllowing / Denying Multiple Lints\n   Accumulate rules and categories from left to right on the command-line.\n   For example `-D correctness -A no-debugger` or `-A all -D no-debugger`.\n   The categories are:\n   * `correctness` - code that is outright wrong or useless (default).\n   * `suspicious`  - code that is most likely wrong or useless.\n   * `pedantic`    - lints which are rather strict or have occasional false positives.\n   * `style`       - code that should be written in a more idiomatic way.\n   * `nursery`     - new lints that are still under development.\n\n\nthread 'tokio-runtime-worker' panicked at library/std/src/io/stdio.rs:1165:9:\nfailed printing to stdout: Broken pipe (os error 32)\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n[Vite+] run error: [Error: Panic in async function] { code: 'GenericFailure' }\n"
  },
  {
    "path": "packages/cli/snap-tests-todo/test-panicked-fix/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"commands\": [\"vp lint --help | head -n 20 # print help message and no panicked\"]\n}\n"
  },
  {
    "path": "packages/cli/src/__tests__/index.spec.ts",
    "content": "import { expect, test } from '@voidzero-dev/vite-plus-test';\n\nimport {\n  configDefaults,\n  coverageConfigDefaults,\n  defaultExclude,\n  defaultInclude,\n  defaultBrowserPort,\n  defineConfig,\n  defineProject,\n} from '../index';\n\ntest('should keep vitest exports stable', () => {\n  expect(defineConfig).toBeTypeOf('function');\n  expect(defineProject).toBeTypeOf('function');\n  expect(configDefaults).toBeDefined();\n  expect(coverageConfigDefaults).toBeDefined();\n  expect(defaultExclude).toBeDefined();\n  expect(defaultInclude).toBeDefined();\n  expect(defaultBrowserPort).toBeDefined();\n});\n\ntest('should support lazy loading of plugins', async () => {\n  const config = await defineConfig({\n    lazy: () => Promise.resolve({ plugins: [{ name: 'test' }] }),\n  });\n  expect(config.plugins?.length).toBe(1);\n});\n\ntest('should merge lazy plugins with existing plugins', async () => {\n  const config = await defineConfig({\n    plugins: [{ name: 'existing' }],\n    lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }),\n  });\n  expect(config.plugins?.length).toBe(2);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('existing');\n  expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy');\n});\n\ntest('should handle lazy with empty plugins array', async () => {\n  const config = await defineConfig({\n    lazy: () => Promise.resolve({ plugins: [] }),\n  });\n  expect(config.plugins?.length).toBe(0);\n});\n\ntest('should handle lazy returning undefined plugins', async () => {\n  const config = await defineConfig({\n    lazy: () => Promise.resolve({}),\n  });\n  expect(config.plugins?.length).toBe(0);\n});\n\ntest('should handle Promise config with lazy', async () => {\n  const config = await defineConfig(\n    Promise.resolve({\n      lazy: () => Promise.resolve({ plugins: [{ name: 'lazy-from-promise' }] }),\n    }),\n  );\n  expect(config.plugins?.length).toBe(1);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('lazy-from-promise');\n});\n\ntest('should handle Promise config with lazy and existing plugins', async () => {\n  const config = await defineConfig(\n    Promise.resolve({\n      plugins: [{ name: 'existing' }],\n      lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }),\n    }),\n  );\n  expect(config.plugins?.length).toBe(2);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('existing');\n  expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy');\n});\n\ntest('should handle Promise config without lazy', async () => {\n  const config = await defineConfig(\n    Promise.resolve({\n      plugins: [{ name: 'no-lazy' }],\n    }),\n  );\n  expect(config.plugins?.length).toBe(1);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('no-lazy');\n});\n\ntest('should handle function config with lazy', async () => {\n  const configFn = defineConfig(() => ({\n    lazy: () => Promise.resolve({ plugins: [{ name: 'lazy-from-fn' }] }),\n  }));\n  expect(typeof configFn).toBe('function');\n  const config = await configFn({ command: 'build', mode: 'production' });\n  expect(config.plugins?.length).toBe(1);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('lazy-from-fn');\n});\n\ntest('should handle function config with lazy and existing plugins', async () => {\n  const configFn = defineConfig(() => ({\n    plugins: [{ name: 'existing' }],\n    lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }),\n  }));\n  const config = await configFn({ command: 'build', mode: 'production' });\n  expect(config.plugins?.length).toBe(2);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('existing');\n  expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy');\n});\n\ntest('should handle function config without lazy', () => {\n  const configFn = defineConfig(() => ({\n    plugins: [{ name: 'no-lazy' }],\n  }));\n  const config = configFn({ command: 'build', mode: 'production' });\n  expect(config.plugins?.length).toBe(1);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('no-lazy');\n});\n\ntest('should handle async function config with lazy', async () => {\n  const configFn = defineConfig(async () => ({\n    lazy: () => Promise.resolve({ plugins: [{ name: 'lazy-from-async-fn' }] }),\n  }));\n  const config = await configFn({ command: 'build', mode: 'production' });\n  expect(config.plugins?.length).toBe(1);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('lazy-from-async-fn');\n});\n\ntest('should handle async function config with lazy and existing plugins', async () => {\n  const configFn = defineConfig(async () => ({\n    plugins: [{ name: 'existing' }],\n    lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }),\n  }));\n  const config = await configFn({ command: 'build', mode: 'production' });\n  expect(config.plugins?.length).toBe(2);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('existing');\n  expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy');\n});\n\ntest('should handle async function config without lazy', async () => {\n  const configFn = defineConfig(async () => ({\n    plugins: [{ name: 'no-lazy' }],\n  }));\n  const config = await configFn({ command: 'build', mode: 'production' });\n  expect(config.plugins?.length).toBe(1);\n  expect((config.plugins?.[0] as { name: string })?.name).toBe('no-lazy');\n});\n"
  },
  {
    "path": "packages/cli/src/__tests__/init-config.spec.ts",
    "content": "import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it } from 'vitest';\nimport { vi } from 'vitest';\n\nimport { applyToolInitConfigToViteConfig, inspectInitCommand } from '../init-config.js';\n\nconst tempDirs: string[] = [];\n\nfunction createTempDir() {\n  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-init-config-'));\n  tempDirs.push(dir);\n  // oxfmt auto-discovers vite.config.ts and needs to resolve imports\n  fs.writeFileSync(path.join(dir, 'package.json'), '{\"type\":\"module\"}');\n  const stubDir = path.join(dir, 'node_modules', 'vite-plus');\n  fs.mkdirSync(stubDir, { recursive: true });\n  fs.writeFileSync(path.join(stubDir, 'package.json'), '{\"type\":\"module\",\"main\":\"index.js\"}');\n  fs.writeFileSync(path.join(stubDir, 'index.js'), 'export const defineConfig = c => c;\\n');\n  return dir;\n}\n\nafterEach(() => {\n  for (const dir of tempDirs.splice(0, tempDirs.length)) {\n    fs.rmSync(dir, { recursive: true, force: true });\n  }\n  vi.clearAllMocks();\n});\n\ndescribe('applyToolInitConfigToViteConfig', () => {\n  it('returns false for non-init command invocations', async () => {\n    const projectPath = createTempDir();\n    await expect(\n      applyToolInitConfigToViteConfig('lint', ['src/index.ts'], projectPath),\n    ).resolves.toEqual({ handled: false });\n  });\n\n  it('creates vite.config.ts and writes lint config with options for vp lint --init', async () => {\n    const projectPath = createTempDir();\n    fs.writeFileSync(\n      path.join(projectPath, '.oxlintrc.json'),\n      JSON.stringify(\n        {\n          rules: {\n            eqeqeq: 'warn',\n          },\n        },\n        null,\n        2,\n      ),\n    );\n\n    const result = await applyToolInitConfigToViteConfig('lint', ['--init'], projectPath);\n    expect(result.handled).toBe(true);\n    expect(result.action).toBe('added');\n\n    const viteConfigPath = path.join(projectPath, 'vite.config.ts');\n    expect(fs.existsSync(viteConfigPath)).toBe(true);\n    const content = fs.readFileSync(viteConfigPath, 'utf8');\n    expect(content).toContain('import { defineConfig } from');\n    expect(content).toContain('vite-plus');\n    expect(content).toContain('typeAware');\n    expect(content).toContain('typeCheck');\n    expect(fs.existsSync(path.join(projectPath, '.oxlintrc.json'))).toBe(false);\n  });\n\n  it('ignores generated lint init defaults and still writes lint with options', async () => {\n    const projectPath = createTempDir();\n    fs.writeFileSync(\n      path.join(projectPath, '.oxlintrc.json'),\n      JSON.stringify(\n        {\n          plugins: null,\n          categories: {},\n          rules: {},\n          settings: {\n            'jsx-a11y': {\n              polymorphicPropName: null,\n              components: {},\n              attributes: {},\n            },\n            next: {\n              rootDir: [],\n            },\n            react: {\n              formComponents: [],\n              linkComponents: [],\n              version: null,\n              componentWrapperFunctions: [],\n            },\n            jsdoc: {\n              ignorePrivate: false,\n              ignoreInternal: false,\n              ignoreReplacesDocs: true,\n              overrideReplacesDocs: true,\n              augmentsExtendsReplacesDocs: false,\n              implementsReplacesDocs: false,\n              exemptDestructuredRootsFromChecks: false,\n              tagNamePreference: {},\n            },\n            vitest: {\n              typecheck: false,\n            },\n          },\n          env: {\n            builtin: true,\n          },\n          globals: {},\n          ignorePatterns: [],\n        },\n        null,\n        2,\n      ),\n    );\n\n    const result = await applyToolInitConfigToViteConfig('lint', ['--init'], projectPath);\n    expect(result.handled).toBe(true);\n    expect(result.action).toBe('added');\n\n    const content = fs.readFileSync(path.join(projectPath, 'vite.config.ts'), 'utf8');\n    expect(content).toContain('typeAware');\n    expect(content).toContain('typeCheck');\n    expect(content).not.toContain('jsx-a11y');\n    expect(content).not.toContain('ignorePatterns');\n  });\n\n  it('inlines fmt migrate output into existing vite config', async () => {\n    const projectPath = createTempDir();\n    const viteConfigPath = path.join(projectPath, 'vite.config.ts');\n    fs.writeFileSync(\n      viteConfigPath,\n      `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n});\n`,\n    );\n    fs.writeFileSync(path.join(projectPath, '.oxfmtrc.json'), '{\\n  \"semi\": true\\n}\\n');\n\n    const result = await applyToolInitConfigToViteConfig(\n      'fmt',\n      ['--migrate=prettier'],\n      projectPath,\n    );\n    expect(result.handled).toBe(true);\n    expect(result.action).toBe('added');\n\n    const content = fs.readFileSync(viteConfigPath, 'utf8');\n    expect(content).toContain('fmt:');\n    expect(content).toContain('semi');\n    expect(fs.existsSync(path.join(projectPath, '.oxfmtrc.json'))).toBe(false);\n  });\n\n  it('uses explicit --config path when provided', async () => {\n    const projectPath = createTempDir();\n    const customConfigPath = path.join(projectPath, 'custom-oxfmt.json');\n    fs.writeFileSync(customConfigPath, '{\\n  \"tabWidth\": 4\\n}\\n');\n\n    const result = await applyToolInitConfigToViteConfig(\n      'fmt',\n      ['--init', '--config', 'custom-oxfmt.json'],\n      projectPath,\n    );\n    expect(result.handled).toBe(true);\n    expect(result.action).toBe('added');\n\n    const content = fs.readFileSync(path.join(projectPath, 'vite.config.ts'), 'utf8');\n    expect(content).toContain('fmt:');\n    expect(content).toContain('tabWidth');\n    expect(fs.existsSync(customConfigPath)).toBe(false);\n  });\n\n  it('removes generated file when key already exists', async () => {\n    const projectPath = createTempDir();\n    const viteConfigPath = path.join(projectPath, 'vite.config.ts');\n    const existing = `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    rules: {},\n  },\n});\n`;\n    fs.writeFileSync(viteConfigPath, existing);\n    fs.writeFileSync(\n      path.join(projectPath, '.oxlintrc.json'),\n      '{\\n  \"rules\": { \"no-console\": \"warn\" }\\n}\\n',\n    );\n\n    const result = await applyToolInitConfigToViteConfig('lint', ['--init'], projectPath);\n    expect(result.handled).toBe(true);\n    expect(result.action).toBe('skipped-existing');\n    expect(fs.readFileSync(viteConfigPath, 'utf8')).toBe(existing);\n    expect(fs.existsSync(path.join(projectPath, '.oxlintrc.json'))).toBe(false);\n  });\n\n  it('detects existing init key before running native init', () => {\n    const projectPath = createTempDir();\n    const viteConfigPath = path.join(projectPath, 'vite.config.ts');\n    fs.writeFileSync(\n      viteConfigPath,\n      `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  fmt: {},\n});\n`,\n    );\n    const inspection = inspectInitCommand('fmt', ['--init'], projectPath);\n    expect(inspection.handled).toBe(true);\n    expect(inspection.configKey).toBe('fmt');\n    expect(inspection.hasExistingConfigKey).toBe(true);\n    expect(inspection.existingViteConfigPath).toBe(viteConfigPath);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/__tests__/pack.spec.ts",
    "content": "import { expect, test } from '@voidzero-dev/vite-plus-test';\n\nimport {\n  build,\n  defineConfig,\n  globalLogger,\n  mergeConfig,\n  resolveUserConfig,\n  buildWithConfigs,\n  enableDebug,\n} from '../pack';\n\ntest('should export all pack APIs from @voidzero-dev/vite-plus-core/pack', () => {\n  expect(defineConfig).toBeTypeOf('function');\n  expect(build).toBeTypeOf('function');\n  expect(globalLogger).toBeDefined();\n  expect(mergeConfig).toBeTypeOf('function');\n  expect(resolveUserConfig).toBeTypeOf('function');\n  expect(buildWithConfigs).toBeTypeOf('function');\n  expect(enableDebug).toBeTypeOf('function');\n});\n"
  },
  {
    "path": "packages/cli/src/__tests__/resolve-vite-config.spec.ts",
    "content": "import fs from 'node:fs';\nimport { mkdtempSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, beforeEach, describe, expect, it } from 'vitest';\n\nimport { findViteConfigUp } from '../resolve-vite-config';\n\ndescribe('findViteConfigUp', () => {\n  let tempDir: string;\n\n  beforeEach(() => {\n    // Resolve symlinks (macOS /var -> /private/var) to match path.resolve behavior\n    tempDir = fs.realpathSync(mkdtempSync(path.join(tmpdir(), 'vite-config-test-')));\n  });\n\n  afterEach(() => {\n    fs.rmSync(tempDir, { recursive: true, force: true });\n  });\n\n  it('should find config in the start directory', () => {\n    fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), '');\n    const result = findViteConfigUp(tempDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.ts'));\n  });\n\n  it('should find config in a parent directory', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.ts'));\n  });\n\n  it('should find config in an intermediate directory', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib', 'src');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'packages', 'vite.config.ts'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'packages', 'vite.config.ts'));\n  });\n\n  it('should return undefined when no config exists', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBeUndefined();\n  });\n\n  it('should not traverse beyond stopDir', () => {\n    const parentConfig = path.join(tempDir, 'vite.config.ts');\n    fs.writeFileSync(parentConfig, '');\n    const stopDir = path.join(tempDir, 'packages');\n    const subDir = path.join(stopDir, 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n\n    const result = findViteConfigUp(subDir, stopDir);\n    // Should not find the config in tempDir because stopDir is packages/\n    expect(result).toBeUndefined();\n  });\n\n  it('should prefer the closest config file', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.ts'), '');\n    fs.writeFileSync(path.join(tempDir, 'packages', 'vite.config.ts'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'packages', 'vite.config.ts'));\n  });\n\n  it('should find .js config files', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.js'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.js'));\n  });\n\n  it('should find .mts config files', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.mts'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.mts'));\n  });\n\n  it('should find .cjs config files', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.cjs'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.cjs'));\n  });\n\n  it('should find .cts config files', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.cts'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.cts'));\n  });\n\n  it('should find .mjs config files', () => {\n    const subDir = path.join(tempDir, 'packages', 'my-lib');\n    fs.mkdirSync(subDir, { recursive: true });\n    fs.writeFileSync(path.join(tempDir, 'vite.config.mjs'), '');\n\n    const result = findViteConfigUp(subDir, tempDir);\n    expect(result).toBe(path.join(tempDir, 'vite.config.mjs'));\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/bin.ts",
    "content": "/**\n * Unified entry point for both the local CLI (via bin/vp) and the global CLI (via Rust vp binary).\n *\n * Global commands (create, migrate, config, mcp, staged, --version) are handled by rolldown-bundled modules.\n * All other commands are delegated to the Rust core through NAPI bindings, which\n * uses JavaScript tool resolver functions to locate tool binaries.\n *\n * When called from the global CLI, the Rust binary resolves the project's local\n * vite-plus installation using oxc_resolver and runs its dist/bin.js directly.\n * If no local installation is found, this global dist/bin.js is used as fallback.\n */\n\nimport path from 'node:path';\n\nimport { run } from '../binding/index.js';\nimport { applyToolInitConfigToViteConfig, inspectInitCommand } from './init-config.js';\nimport { doc } from './resolve-doc.js';\nimport { fmt } from './resolve-fmt.js';\nimport { lint } from './resolve-lint.js';\nimport { pack } from './resolve-pack.js';\nimport { test } from './resolve-test.js';\nimport { resolveUniversalViteConfig } from './resolve-vite-config.js';\nimport { vite } from './resolve-vite.js';\nimport { accent, errorMsg, log } from './utils/terminal.js';\n\nfunction getErrorMessage(err: unknown): string {\n  if (err instanceof Error) {\n    return err.message;\n  }\n\n  if (typeof err === 'object' && err && 'message' in err && typeof err.message === 'string') {\n    return err.message;\n  }\n\n  return String(err);\n}\n\n// Parse command line arguments\nlet args = process.argv.slice(2);\n\n// Transform `vp help [command]` into `vp [command] --help`\nif (args[0] === 'help' && args[1]) {\n  args = [args[1], '--help', ...args.slice(2)];\n  process.argv = process.argv.slice(0, 2).concat(args);\n}\n\nconst command = args[0];\n\n// Global commands — handled by rolldown-bundled modules in dist/global/\n// These modules only exist after rolldown bundles them, so TS cannot resolve them.\nif (command === 'create') {\n  // @ts-ignore — rolldown output\n  await import('./global/create.js');\n} else if (command === 'migrate') {\n  // @ts-ignore — rolldown output\n  await import('./global/migrate.js');\n} else if (command === 'config') {\n  // @ts-ignore — rolldown output\n  await import('./global/config.js');\n} else if (command === 'mcp') {\n  // @ts-ignore — rolldown output\n  await import('./global/mcp.js');\n} else if (command === '--version' || command === '-V') {\n  // @ts-ignore — rolldown output\n  await import('./global/version.js');\n} else if (command === 'staged') {\n  // @ts-ignore — rolldown output\n  await import('./global/staged.js');\n} else {\n  // All other commands — delegate to Rust core via NAPI binding\n  try {\n    const initInspection = inspectInitCommand(command, args.slice(1));\n    if (\n      initInspection.handled &&\n      initInspection.configKey &&\n      initInspection.hasExistingConfigKey &&\n      initInspection.existingViteConfigPath\n    ) {\n      log(\n        `Skipped initialization: '${accent(initInspection.configKey)}' already exists in '${accent(path.basename(initInspection.existingViteConfigPath))}'.`,\n      );\n      process.exit(0);\n    }\n\n    const exitCode = await run({\n      lint,\n      pack,\n      fmt,\n      vite,\n      test,\n      doc,\n      resolveUniversalViteConfig,\n      args: process.argv.slice(2),\n    });\n\n    let finalExitCode = exitCode;\n    if (exitCode === 0) {\n      try {\n        const result = await applyToolInitConfigToViteConfig(command, args.slice(1));\n        if (\n          result.handled &&\n          result.action === 'added' &&\n          result.configKey &&\n          result.viteConfigPath\n        ) {\n          log(\n            `Added '${accent(result.configKey)}' to '${accent(path.basename(result.viteConfigPath))}'.`,\n          );\n        }\n        if (\n          result.handled &&\n          result.action === 'skipped-existing' &&\n          result.configKey &&\n          result.viteConfigPath\n        ) {\n          log(\n            `Skipped initialization: '${accent(result.configKey)}' already exists in '${accent(path.basename(result.viteConfigPath))}'.`,\n          );\n        }\n      } catch (err) {\n        console.error('[Vite+] Failed to initialize config in vite.config.ts:', err);\n        finalExitCode = 1;\n      }\n    }\n\n    process.exit(finalExitCode);\n  } catch (err) {\n    errorMsg(getErrorMessage(err));\n    process.exit(1);\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/config/__tests__/hooks.spec.ts",
    "content": "import { execSync } from 'node:child_process';\nimport { existsSync, mkdtempSync, rmSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { hookScript, install } from '../hooks.js';\n\nfunction countDirnameCalls(script: string): number {\n  // Count nested dirname calls in the `d=...` line\n  const match = script.match(/^d=(.+)$/m);\n  if (!match) {\n    return 0;\n  }\n  return (match[1].match(/dirname/g) ?? []).length;\n}\n\ndescribe('install', () => {\n  it.skipIf(process.platform === 'win32')(\n    'should create _/pre-commit but not pre-commit in hooks dir root',\n    () => {\n      const tmp = mkdtempSync(join(tmpdir(), 'hooks-test-'));\n      const originalCwd = process.cwd();\n      try {\n        // Set up a temporary git repo\n        execSync('git init', { cwd: tmp, stdio: 'ignore' });\n        process.chdir(tmp);\n\n        const hooksDir = '.vite-hooks';\n        const result = install(hooksDir);\n        expect(result.isError).toBe(false);\n\n        // install() creates the internal shim at _/pre-commit\n        expect(existsSync(join(tmp, hooksDir, '_', 'pre-commit'))).toBe(true);\n        // install() does NOT create pre-commit at the hooks dir root\n        expect(existsSync(join(tmp, hooksDir, 'pre-commit'))).toBe(false);\n      } finally {\n        process.chdir(originalCwd);\n        rmSync(tmp, { recursive: true, force: true });\n      }\n    },\n  );\n});\n\ndescribe('hookScript', () => {\n  it('should compute correct depth for simple dir', () => {\n    // \".vite-hooks\" → 1 segment → depth 3\n    const script = hookScript('.vite-hooks');\n    expect(countDirnameCalls(script)).toBe(3);\n  });\n\n  it('should compute correct depth for nested dir', () => {\n    // \".config/husky\" → 2 segments → depth 4\n    const script = hookScript('.config/husky');\n    expect(countDirnameCalls(script)).toBe(4);\n  });\n\n  it('should handle ./ prefix correctly (bug case)', () => {\n    // \"./.config/husky\" should produce same depth as \".config/husky\"\n    // Before fix: filter(Boolean) kept \".\" → 3 segments → depth 5 (wrong)\n    // After fix: filter out \".\" → 2 segments → depth 4 (correct)\n    const withDot = hookScript('./.config/husky');\n    const withoutDot = hookScript('.config/husky');\n    expect(countDirnameCalls(withDot)).toBe(countDirnameCalls(withoutDot));\n    expect(countDirnameCalls(withDot)).toBe(4);\n  });\n\n  it('should handle ./ prefix for simple dir', () => {\n    // \"./custom-hooks\" should produce same depth as \"custom-hooks\"\n    const withDot = hookScript('./custom-hooks');\n    const withoutDot = hookScript('custom-hooks');\n    expect(countDirnameCalls(withDot)).toBe(countDirnameCalls(withoutDot));\n    expect(countDirnameCalls(withDot)).toBe(3);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/config/bin.ts",
    "content": "// Unified `vp config` command — hooks setup + agent instruction updates.\n//\n// Hooks: interactive mode prompts on first run; non-interactive installs by default.\n// Agent instructions: silently updates existing files with Vite+ markers.\n// Never creates new agent files. Same behavior for prepare and manual runs.\n\nimport { existsSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport mri from 'mri';\n\nimport { vitePlusHeader } from '../../binding/index.js';\nimport { ensurePreCommitHook } from '../migration/migrator.js';\nimport { updateExistingAgentInstructions } from '../utils/agent.js';\nimport { renderCliDoc } from '../utils/help.js';\nimport { defaultInteractive, promptGitHooks } from '../utils/prompts.js';\nimport { log } from '../utils/terminal.js';\nimport { install } from './hooks.js';\n\nasync function main() {\n  const args = mri(process.argv.slice(3), {\n    boolean: ['help', 'hooks-only'],\n    string: ['hooks-dir'],\n    alias: { h: 'help' },\n  });\n\n  if (args.help) {\n    const helpMessage = renderCliDoc({\n      usage: 'vp config [OPTIONS]',\n      summary: 'Configure Vite+ for the current project (hooks + agent integration).',\n      documentationUrl: 'https://viteplus.dev/guide/commit-hooks',\n      sections: [\n        {\n          title: 'Options',\n          rows: [\n            {\n              label: '--hooks-dir <path>',\n              description: 'Custom hooks directory (default: .vite-hooks)',\n            },\n            { label: '-h, --help', description: 'Show this help message' },\n          ],\n        },\n        {\n          title: 'Environment',\n          rows: [{ label: 'VITE_GIT_HOOKS=0', description: 'Skip hook installation' }],\n        },\n      ],\n    });\n    log(vitePlusHeader() + '\\n');\n    log(helpMessage);\n    return;\n  }\n\n  const dir = args['hooks-dir'] as string | undefined;\n  const hooksOnly = args['hooks-only'] as boolean;\n  const interactive = defaultInteractive();\n  const isPrepareScript = process.env.npm_lifecycle_event === 'prepare';\n  const root = process.cwd();\n\n  // --- Step 1: Hooks setup ---\n  const hooksDir = dir ?? '.vite-hooks';\n  const isFirstHooksRun = !existsSync(join(root, hooksDir, '_', 'pre-commit'));\n\n  let shouldSetupHooks = true;\n  if (interactive && isFirstHooksRun && !dir && !isPrepareScript) {\n    // --hooks-dir implies agreement; only prompt when using default dir on first run\n    // prepare script implies the project opted into hooks — install automatically\n    shouldSetupHooks = await promptGitHooks({ interactive });\n  }\n\n  if (shouldSetupHooks) {\n    const { message, isError } = install(dir);\n    if (message) {\n      log(message);\n      if (isError) {\n        process.exit(1);\n      }\n    }\n\n    // Only create pre-commit hook when install() succeeded (empty message).\n    // Skip when hooks were disabled or git is unavailable.\n    if (!message) {\n      ensurePreCommitHook(root, hooksDir);\n    }\n  }\n\n  // --- Step 2: Update agent instructions if Vite+ header exists and is outdated ---\n  if (!hooksOnly) {\n    updateExistingAgentInstructions(root);\n  }\n}\n\nvoid main();\n"
  },
  {
    "path": "packages/cli/src/config/hooks.ts",
    "content": "import { spawnSync } from 'node:child_process';\nimport { mkdirSync, rmSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nconst HOOKS = [\n  'pre-commit',\n  'pre-merge-commit',\n  'prepare-commit-msg',\n  'commit-msg',\n  'post-commit',\n  'applypatch-msg',\n  'pre-applypatch',\n  'post-applypatch',\n  'pre-rebase',\n  'post-rewrite',\n  'post-checkout',\n  'post-merge',\n  'pre-push',\n  'pre-auto-gc',\n];\n\n// Build nested dirname expression: depth 3 → dirname \"$(dirname \"$(dirname \"$0\"))\"\nfunction nestedDirname(depth: number): string {\n  let expr = '\"$0\"';\n  for (let i = 0; i < depth; i++) {\n    expr = `\"$(dirname ${expr})\"`;\n  }\n  return expr;\n}\n\n// The shell script that dispatches to user-defined hooks in <dir>/\n// `depth` = number of path segments in `dir` + 2 (for `_` subdir + hook filename)\nexport function hookScript(dir: string): string {\n  // Count segments: \".vite-hooks\" → 1, \".config/husky\" → 2\n  // Filter out empty strings and '.' to handle paths like \"./.config/husky\"\n  const segments = dir.split('/').filter((s) => s !== '' && s !== '.').length;\n  const depth = segments + 2; // +2 for _ subdir and hook filename\n  const rootExpr = nestedDirname(depth);\n  return `#!/usr/bin/env sh\n{ [ \"$HUSKY\" = \"2\" ] || [ \"$VITE_GIT_HOOKS\" = \"2\" ]; } && set -x\nn=$(basename \"$0\")\ns=$(dirname \"$(dirname \"$0\")\")/$n\n\n[ ! -f \"$s\" ] && exit 0\n\ni=\"\\${XDG_CONFIG_HOME:-$HOME/.config}/vite-plus/hooks-init.sh\"\n[ ! -f \"$i\" ] && i=\"\\${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh\"\n[ -f \"$i\" ] && . \"$i\"\n\n{ [ \"\\${HUSKY-}\" = \"0\" ] || [ \"\\${VITE_GIT_HOOKS-}\" = \"0\" ]; } && exit 0\n\nd=${rootExpr}\nexport PATH=\"$d/node_modules/.bin:$PATH\"\nsh -e \"$s\" \"$@\"\nc=$?\n\n[ $c != 0 ] && echo \"VITE+ - $n script failed (code $c)\"\n[ $c = 127 ] && echo \"VITE+ - command not found in PATH=$PATH\"\nexit $c`;\n}\n\nexport interface InstallResult {\n  message: string;\n  isError: boolean;\n}\n\nexport function install(dir = '.vite-hooks'): InstallResult {\n  if (process.env.HUSKY === '0' || process.env.VITE_GIT_HOOKS === '0') {\n    return { message: 'skip install (git hooks disabled)', isError: false };\n  }\n  if (dir.includes('..')) {\n    return { message: '.. not allowed', isError: false };\n  }\n  // Use --show-prefix to get the relative path from git root to cwd.\n  // This avoids Windows path normalization issues (MSYS paths, 8.3 short names)\n  // that make path.relative() unreliable across git and Node.js representations.\n  const prefixResult = spawnSync('git', ['rev-parse', '--show-prefix']);\n  if (prefixResult.status == null) {\n    return { message: 'git command not found', isError: true };\n  }\n  if (prefixResult.status !== 0) {\n    return { message: \".git can't be found\", isError: false };\n  }\n\n  const internal = (x = '') => join(dir, '_', x);\n  const rel = prefixResult.stdout.toString().trim().replace(/\\/$/, '');\n  const target = rel ? `${rel}/${dir}/_` : `${dir}/_`;\n  const checkResult = spawnSync('git', ['config', '--local', 'core.hooksPath']);\n  const existingHooksPath = checkResult.status === 0 ? checkResult.stdout?.toString().trim() : '';\n  if (\n    existingHooksPath &&\n    existingHooksPath !== target &&\n    existingHooksPath !== '.husky' &&\n    !existingHooksPath.startsWith('.husky/')\n  ) {\n    return {\n      message: `core.hooksPath is already set to \"${existingHooksPath}\", skipping`,\n      isError: false,\n    };\n  }\n\n  const { status, stderr } = spawnSync('git', ['config', 'core.hooksPath', target]);\n  if (status == null) {\n    return { message: 'git command not found', isError: true };\n  }\n  if (status) {\n    return { message: '' + stderr, isError: true };\n  }\n\n  rmSync(internal('husky.sh'), { force: true });\n  mkdirSync(internal(), { recursive: true });\n  writeFileSync(internal('.gitignore'), '*');\n  writeFileSync(internal('h'), hookScript(dir), { mode: 0o755 });\n  for (const hook of HOOKS) {\n    writeFileSync(internal(hook), `#!/usr/bin/env sh\\n. \"$(dirname \"$0\")/h\"`, { mode: 0o755 });\n  }\n  return { message: '', isError: false };\n}\n"
  },
  {
    "path": "packages/cli/src/create/__tests__/__snapshots__/utils.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`formatTargetDir > should format target dir with invalid input 1`] = `\n{\n  \"directory\": \"\",\n  \"error\": \"Absolute path is not allowed\",\n  \"packageName\": \"\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with invalid input 2`] = `\n{\n  \"directory\": \"\",\n  \"error\": \"Parsed package name \"@scope\" is invalid: name can only contain URL-friendly characters\",\n  \"packageName\": \"\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with invalid input 3`] = `\n{\n  \"directory\": \"\",\n  \"error\": \"Relative path contains \"..\" which is not allowed\",\n  \"packageName\": \"\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with invalid package name 1`] = `\"Parsed package name \"my-package@\" is invalid: name can only contain URL-friendly characters\"`;\n\nexports[`formatTargetDir > should format target dir with invalid package name 2`] = `\"Parsed package name \"my-package@1.0.0\" is invalid: name can only contain URL-friendly characters\"`;\n\nexports[`formatTargetDir > should format target dir with valid input 1`] = `\n{\n  \"directory\": \"my-package\",\n  \"packageName\": \"my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 2`] = `\n{\n  \"directory\": \"my-package\",\n  \"packageName\": \"my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 3`] = `\n{\n  \"directory\": \"my-package\",\n  \"packageName\": \"@my-scope/my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 4`] = `\n{\n  \"directory\": \"foo/my-package\",\n  \"packageName\": \"@my-scope/my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 5`] = `\n{\n  \"directory\": \"foo/my-package\",\n  \"packageName\": \"@my-scope/my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 6`] = `\n{\n  \"directory\": \"foo/bar/my-package\",\n  \"packageName\": \"@scope/my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 7`] = `\n{\n  \"directory\": \"foo/bar/my-package\",\n  \"packageName\": \"@scope/my-package\",\n}\n`;\n\nexports[`formatTargetDir > should format target dir with valid input 8`] = `\n{\n  \"directory\": \"foo/bar/@scope/my-package/sub-package\",\n  \"packageName\": \"sub-package\",\n}\n`;\n"
  },
  {
    "path": "packages/cli/src/create/__tests__/discovery.spec.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  discoverTemplate,\n  expandCreateShorthand,\n  inferGitHubRepoName,\n  parseGitHubUrl,\n} from '../discovery.js';\n\ndescribe('expandCreateShorthand', () => {\n  it('should expand unscoped names to create-* packages', () => {\n    expect(expandCreateShorthand('vite')).toBe('create-vite');\n    expect(expandCreateShorthand('next-app')).toBe('create-next-app');\n    expect(expandCreateShorthand('nuxt')).toBe('create-nuxt');\n    expect(expandCreateShorthand('vue')).toBe('create-vue');\n  });\n\n  it('should expand unscoped names with version', () => {\n    expect(expandCreateShorthand('vite@latest')).toBe('create-vite@latest');\n    expect(expandCreateShorthand('vite@5.0.0')).toBe('create-vite@5.0.0');\n  });\n\n  it('should expand scoped names to @scope/create-* packages', () => {\n    expect(expandCreateShorthand('@tanstack/start')).toBe('@tanstack/create-start');\n    expect(expandCreateShorthand('@my-org/app')).toBe('@my-org/create-app');\n  });\n\n  it('should expand scoped names with version', () => {\n    expect(expandCreateShorthand('@tanstack/start@latest')).toBe('@tanstack/create-start@latest');\n    expect(expandCreateShorthand('@tanstack/start@1.0.0')).toBe('@tanstack/create-start@1.0.0');\n  });\n\n  it('should not expand names already starting with create-', () => {\n    expect(expandCreateShorthand('create-vite')).toBe('create-vite');\n    expect(expandCreateShorthand('create-vite@latest')).toBe('create-vite@latest');\n    expect(expandCreateShorthand('create-next-app')).toBe('create-next-app');\n    expect(expandCreateShorthand('@tanstack/create-start')).toBe('@tanstack/create-start');\n    expect(expandCreateShorthand('@tanstack/create-start@latest')).toBe(\n      '@tanstack/create-start@latest',\n    );\n  });\n\n  it('should not expand builtin templates (vite:*)', () => {\n    expect(expandCreateShorthand('vite:monorepo')).toBe('vite:monorepo');\n    expect(expandCreateShorthand('vite:application')).toBe('vite:application');\n    expect(expandCreateShorthand('vite:library')).toBe('vite:library');\n    expect(expandCreateShorthand('vite:generator')).toBe('vite:generator');\n  });\n\n  it('should not expand GitHub URLs', () => {\n    expect(expandCreateShorthand('github:user/repo')).toBe('github:user/repo');\n    expect(expandCreateShorthand('https://github.com/user/repo')).toBe(\n      'https://github.com/user/repo',\n    );\n  });\n\n  it('should not expand local paths', () => {\n    expect(expandCreateShorthand('./local-template')).toBe('./local-template');\n    expect(expandCreateShorthand('../parent-template')).toBe('../parent-template');\n    expect(expandCreateShorthand('/absolute/path')).toBe('/absolute/path');\n  });\n\n  it('should expand scope-only input to @scope/create', () => {\n    expect(expandCreateShorthand('@scope')).toBe('@scope/create');\n    expect(expandCreateShorthand('@scope@latest')).toBe('@scope/create@latest');\n    expect(expandCreateShorthand('@scope@1.2.3')).toBe('@scope/create@1.2.3');\n  });\n\n  it('should handle special cases where default convention does not apply', () => {\n    expect(expandCreateShorthand('nitro')).toBe('create-nitro-app');\n    expect(expandCreateShorthand('nitro@latest')).toBe('create-nitro-app@latest');\n    expect(expandCreateShorthand('svelte')).toBe('sv');\n    expect(expandCreateShorthand('svelte@latest')).toBe('sv@latest');\n  });\n});\n\ndescribe('GitHub template helpers', () => {\n  it('should parse GitHub shorthand URLs', () => {\n    expect(parseGitHubUrl('github:user/repo')).toBe('user/repo');\n  });\n\n  it('should parse GitHub https URLs', () => {\n    expect(parseGitHubUrl('https://github.com/user/repo')).toBe('user/repo');\n    expect(parseGitHubUrl('https://github.com/user/repo.git')).toBe('user/repo');\n  });\n\n  it('should infer the repository name from GitHub templates', () => {\n    expect(inferGitHubRepoName('github:nkzw-tech/fate-template')).toBe('fate-template');\n    expect(inferGitHubRepoName('https://github.com/nkzw-tech/fate-template')).toBe('fate-template');\n  });\n\n  it('should resolve GitHub templates to degit without reusing the original URL as destination', () => {\n    const template = discoverTemplate('https://github.com/nkzw-tech/fate-template', ['my-app'], {\n      rootDir: '/tmp/workspace',\n      isMonorepo: false,\n      monorepoScope: '',\n      workspacePatterns: [],\n      parentDirs: [],\n      packageManager: 'pnpm',\n      packageManagerVersion: 'latest',\n      downloadPackageManager: {\n        binPrefix: '/tmp/bin',\n        version: '10.0.0',\n      } as never,\n      packages: [],\n    });\n\n    expect(template.command).toBe('degit');\n    expect(template.args).toEqual(['nkzw-tech/fate-template', 'my-app']);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/create/__tests__/initial-template-options.spec.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { getInitialTemplateOptions } from '../initial-template-options.js';\n\ndescribe('getInitialTemplateOptions', () => {\n  it('shows only built-in monorepo, application, and library options outside a monorepo', () => {\n    expect(getInitialTemplateOptions(false)).toEqual([\n      {\n        label: 'Vite+ Monorepo',\n        value: 'vite:monorepo',\n        hint: 'Create a new Vite+ monorepo project',\n      },\n      {\n        label: 'Vite+ Application',\n        value: 'vite:application',\n        hint: 'Create vite applications',\n      },\n      {\n        label: 'Vite+ Library',\n        value: 'vite:library',\n        hint: 'Create vite libraries',\n      },\n    ]);\n  });\n\n  it('shows only built-in application and library options inside a monorepo', () => {\n    expect(getInitialTemplateOptions(true)).toEqual([\n      {\n        label: 'Vite+ Application',\n        value: 'vite:application',\n        hint: 'Create vite applications',\n      },\n      {\n        label: 'Vite+ Library',\n        value: 'vite:library',\n        hint: 'Create vite libraries',\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/create/__tests__/prompts.spec.ts",
    "content": "import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it } from 'vitest';\n\nimport { isTargetDirAvailable, suggestAvailableTargetDir } from '../prompts.js';\n\nconst tempDirs: string[] = [];\n\nafterEach(() => {\n  for (const dir of tempDirs.splice(0)) {\n    fs.rmSync(dir, { recursive: true, force: true });\n  }\n});\n\nfunction makeTempDir() {\n  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-create-'));\n  tempDirs.push(dir);\n  return dir;\n}\n\ndescribe('target directory helpers', () => {\n  it('reports missing directories as available', () => {\n    const cwd = makeTempDir();\n    expect(isTargetDirAvailable(path.join(cwd, 'new-project'))).toBe(true);\n  });\n\n  it('reports non-empty directories as unavailable', () => {\n    const cwd = makeTempDir();\n    const targetDir = path.join(cwd, 'existing-project');\n    fs.mkdirSync(targetDir, { recursive: true });\n    fs.writeFileSync(path.join(targetDir, 'package.json'), '{}');\n\n    expect(isTargetDirAvailable(targetDir)).toBe(false);\n  });\n\n  it('suggests a different target directory when the default already exists', () => {\n    const cwd = makeTempDir();\n    fs.mkdirSync(path.join(cwd, 'fate-template'), { recursive: true });\n    fs.writeFileSync(path.join(cwd, 'fate-template', 'package.json'), '{}');\n\n    expect(suggestAvailableTargetDir('fate-template', cwd)).not.toBe('fate-template');\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/create/__tests__/utils.spec.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport {\n  deriveDefaultPackageName,\n  formatTargetDir,\n  getProjectDirFromPackageName,\n} from '../utils.js';\n\ndescribe('getProjectDirFromPackageName', () => {\n  it('should get project dir from package name', () => {\n    expect(getProjectDirFromPackageName('@my/package')).toBe('package');\n    expect(getProjectDirFromPackageName('my-package')).toBe('my-package');\n  });\n});\n\ndescribe('formatTargetDir', () => {\n  it('should format \".\" as current directory with empty package name', () => {\n    expect(formatTargetDir('.')).toEqual({\n      directory: '.',\n      packageName: '',\n    });\n  });\n\n  it('should format \"./\" as current directory with empty package name', () => {\n    expect(formatTargetDir('./')).toEqual({\n      directory: '.',\n      packageName: '',\n    });\n  });\n\n  it('should format target dir with invalid input', () => {\n    expect(formatTargetDir('/foo/bar')).matchSnapshot();\n    expect(formatTargetDir('@scope/')).matchSnapshot();\n    expect(formatTargetDir('../../foo/bar')).matchSnapshot();\n  });\n\n  // Should work on all platforms (including Windows) - directory must always use forward slashes\n  it('should format target dir with valid input', () => {\n    expect(formatTargetDir('./my-package')).matchSnapshot();\n    expect(formatTargetDir('my-package')).matchSnapshot();\n    expect(formatTargetDir('@my-scope/my-package')).matchSnapshot();\n    expect(formatTargetDir('foo/@my-scope/my-package')).matchSnapshot();\n    expect(formatTargetDir('./foo/@my-scope/my-package')).matchSnapshot();\n    expect(formatTargetDir('./foo/bar/@scope/my-package')).matchSnapshot();\n    expect(formatTargetDir('./foo/bar/@scope/my-package/')).matchSnapshot();\n    expect(formatTargetDir('./foo/bar/@scope/my-package/sub-package')).matchSnapshot();\n  });\n\n  // Regression test for https://github.com/voidzero-dev/vite-plus/issues/938\n  // On Windows, path.join/normalize produce backslashes which break when passed as CLI args.\n  // Nested paths are the critical cases since they involve path separators.\n  it('should always use forward slashes in directory (issue #938)', () => {\n    expect(formatTargetDir('foo/@my-scope/my-package').directory).toBe('foo/my-package');\n    expect(formatTargetDir('./foo/bar/@scope/my-package').directory).toBe('foo/bar/my-package');\n    expect(formatTargetDir('./foo/bar/@scope/my-package/sub-package').directory).toBe(\n      'foo/bar/@scope/my-package/sub-package',\n    );\n  });\n\n  it('should format target dir with invalid package name', () => {\n    expect(formatTargetDir('my-package@').error).matchSnapshot();\n    expect(formatTargetDir('my-package@1.0.0').error).matchSnapshot();\n  });\n});\n\ndescribe('deriveDefaultPackageName', () => {\n  it('should derive package name from directory basename', () => {\n    expect(deriveDefaultPackageName('/home/user/my-app', undefined, 'fallback')).toBe('my-app');\n  });\n\n  it('should derive scoped package name when scope is provided', () => {\n    expect(deriveDefaultPackageName('/home/user/my-app', '@my-scope', 'fallback')).toBe(\n      '@my-scope/my-app',\n    );\n  });\n\n  it('should fallback to random name when directory name is invalid', () => {\n    const result = deriveDefaultPackageName('/home/user/.hidden', undefined, 'vite-plus-app');\n    // directory name starts with '.', so a random name is generated instead\n    expect(result).not.toBe('.hidden');\n    expect(result.length).toBeGreaterThan(0);\n  });\n\n  it('should fallback when directory is filesystem root', () => {\n    const result = deriveDefaultPackageName('/', undefined, 'vite-plus-app');\n    // basename of '/' is empty, so a random name is generated\n    expect(result.length).toBeGreaterThan(0);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/create/bin.ts",
    "content": "import path from 'node:path';\nimport { styleText } from 'node:util';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport mri from 'mri';\n\nimport { vitePlusHeader } from '../../binding/index.js';\nimport {\n  installGitHooks,\n  rewriteMonorepo,\n  rewriteMonorepoProject,\n  rewriteStandaloneProject,\n} from '../migration/migrator.js';\nimport { DependencyType, type WorkspaceInfo } from '../types/index.js';\nimport {\n  detectExistingAgentTargetPaths,\n  selectAgentTargetPaths,\n  writeAgentInstructions,\n} from '../utils/agent.js';\nimport { detectExistingEditor, selectEditor, writeEditorConfigs } from '../utils/editor.js';\nimport { renderCliDoc } from '../utils/help.js';\nimport { displayRelative } from '../utils/path.js';\nimport {\n  type CommandRunSummary,\n  defaultInteractive,\n  downloadPackageManager,\n  promptGitHooks,\n  runViteFmt,\n  runViteInstall,\n  selectPackageManager,\n} from '../utils/prompts.js';\nimport { accent, muted, log, success } from '../utils/terminal.js';\nimport {\n  detectWorkspace,\n  updatePackageJsonWithDeps,\n  updateWorkspaceConfig,\n} from '../utils/workspace.js';\nimport type { ExecutionResult } from './command.js';\nimport { discoverTemplate, inferGitHubRepoName, inferParentDir, isGitHubUrl } from './discovery.js';\nimport { getInitialTemplateOptions } from './initial-template-options.js';\nimport {\n  cancelAndExit,\n  checkProjectDirExists,\n  promptPackageNameAndTargetDir,\n  promptTargetDir,\n  suggestAvailableTargetDir,\n} from './prompts.js';\nimport { getRandomProjectName } from './random-name.js';\nimport {\n  executeBuiltinTemplate,\n  executeMonorepoTemplate,\n  executeRemoteTemplate,\n} from './templates/index.js';\nimport { InitialMonorepoAppDir } from './templates/monorepo.js';\nimport { BuiltinTemplate, TemplateType } from './templates/types.js';\nimport { deriveDefaultPackageName, formatTargetDir } from './utils.js';\n\nconst helpMessage = renderCliDoc({\n  usage: 'vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]',\n  summary: 'Use any builtin, local or remote template with Vite+.',\n  documentationUrl: 'https://viteplus.dev/guide/create',\n  sections: [\n    {\n      title: 'Arguments',\n      rows: [\n        {\n          label: 'TEMPLATE',\n          description: [\n            `Template name. Run \\`${accent('vp create --list')}\\` to see available templates.`,\n            `- Default: ${accent('vite:monorepo')}, ${accent('vite:application')}, ${accent('vite:library')}, ${accent('vite:generator')}`,\n            '- Remote: vite, @tanstack/start, create-next-app,',\n            '  create-nuxt, github:user/repo, https://github.com/user/template-repo, etc.',\n            '- Local: @company/generator-*, ./tools/create-ui-component',\n          ],\n        },\n      ],\n    },\n    {\n      title: 'Options',\n      rows: [\n        { label: '--directory DIR', description: 'Target directory for the generated project.' },\n        {\n          label: '--agent NAME',\n          description: 'Create an agent instructions file for the specified agent.',\n        },\n        {\n          label: '--editor NAME',\n          description: 'Write editor config files for the specified editor.',\n        },\n        {\n          label: '--hooks',\n          description: 'Set up pre-commit hooks (default in non-interactive mode)',\n        },\n        { label: '--no-hooks', description: 'Skip pre-commit hooks setup' },\n        { label: '--verbose', description: 'Show detailed scaffolding output' },\n        { label: '--no-interactive', description: 'Run in non-interactive mode' },\n        { label: '--list', description: 'List all available templates' },\n        { label: '-h, --help', description: 'Show this help message' },\n      ],\n    },\n    {\n      title: 'Template Options',\n      lines: ['  Any arguments after -- are passed directly to the template.'],\n    },\n    {\n      title: 'Examples',\n      lines: [\n        `  ${muted('# Interactive mode')}`,\n        `  ${accent('vp create')}`,\n        '',\n        `  ${muted('# Use existing templates (shorthand expands to create-* packages)')}`,\n        `  ${accent('vp create vite')}`,\n        `  ${accent('vp create @tanstack/start')}`,\n        `  ${accent('vp create svelte')}`,\n        `  ${accent('vp create vite -- --template react-ts')}`,\n        '',\n        `  ${muted('# Full package names also work')}`,\n        `  ${accent('vp create create-vite')}`,\n        `  ${accent('vp create create-next-app')}`,\n        '',\n        `  ${muted('# Create Vite+ monorepo, application, library, or generator scaffolds')}`,\n        `  ${accent('vp create vite:monorepo')}`,\n        `  ${accent('vp create vite:application')}`,\n        `  ${accent('vp create vite:library')}`,\n        `  ${accent('vp create vite:generator')}`,\n        '',\n        `  ${muted('# Use templates from GitHub (via degit)')}`,\n        `  ${accent('vp create github:user/repo')}`,\n        `  ${accent('vp create https://github.com/user/template-repo')}`,\n      ],\n    },\n  ],\n});\n\nconst listTemplatesMessage = renderCliDoc({\n  usage: 'vp create --list',\n  summary: 'List available builtin and popular project templates.',\n  documentationUrl: 'https://viteplus.dev/guide/create',\n  sections: [\n    {\n      title: 'Vite+ Built-in Templates',\n      rows: [\n        { label: 'vite:monorepo', description: 'Create a new monorepo' },\n        { label: 'vite:application', description: 'Create a new application' },\n        { label: 'vite:library', description: 'Create a new library' },\n        { label: 'vite:generator', description: 'Scaffold a new code generator (monorepo only)' },\n      ],\n    },\n    {\n      title: 'Popular Templates (shorthand)',\n      rows: [\n        { label: 'vite', description: 'Official Vite templates (create-vite)' },\n        {\n          label: '@tanstack/start',\n          description: 'TanStack applications (@tanstack/create-start)',\n        },\n        { label: 'next-app', description: 'Next.js application (create-next-app)' },\n        { label: 'nuxt', description: 'Nuxt application (create-nuxt)' },\n        { label: 'react-router', description: 'React Router application (create-react-router)' },\n        { label: 'svelte', description: 'Svelte application (sv create)' },\n        { label: 'vue', description: 'Vue application (create-vue)' },\n      ],\n    },\n    {\n      title: 'Examples',\n      lines: [\n        `  ${accent('vp create')} ${muted('# interactive mode')}`,\n        `  ${accent('vp create vite')} ${muted('# shorthand for create-vite')}`,\n        `  ${accent('vp create @tanstack/start')} ${muted('# shorthand for @tanstack/create-start')}`,\n        `  ${accent('vp create <template> -- <options>')} ${muted('# pass options to the template')}`,\n      ],\n    },\n    {\n      title: 'Tip',\n      lines: [`  You can use any npm template or git repo with ${accent('vp create')}.`],\n    },\n  ],\n});\n\nexport interface Options {\n  directory?: string;\n  interactive: boolean;\n  list: boolean;\n  help: boolean;\n  verbose: boolean;\n  agent?: string | string[] | false;\n  editor?: string;\n  hooks?: boolean;\n}\n\n// Parse CLI arguments: split on '--' separator\nfunction parseArgs() {\n  const args = process.argv.slice(3); // Skip 'node', 'vite'\n  const separatorIndex = args.indexOf('--');\n\n  // Arguments before -- are Vite+ options\n  const viteArgs = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;\n\n  // Arguments after -- are template options\n  const templateArgs = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];\n\n  const parsed = mri<{\n    directory?: string;\n    interactive?: boolean;\n    list?: boolean;\n    help?: boolean;\n    verbose?: boolean;\n    agent?: string | string[] | false;\n    editor?: string;\n    hooks?: boolean;\n  }>(viteArgs, {\n    alias: { h: 'help' },\n    boolean: ['help', 'list', 'all', 'interactive', 'hooks', 'verbose'],\n    string: ['directory', 'agent', 'editor'],\n    default: { interactive: defaultInteractive() },\n  });\n\n  const templateName = parsed._[0] as string | undefined;\n\n  return {\n    templateName,\n    options: {\n      directory: parsed.directory,\n      interactive: parsed.interactive,\n      list: parsed.list || false,\n      help: parsed.help || false,\n      verbose: parsed.verbose || false,\n      agent: parsed.agent,\n      editor: parsed.editor,\n      hooks: parsed.hooks,\n    } as Options,\n    templateArgs,\n  };\n}\n\nfunction describeScaffold(templateName: string, templateArgs: string[]) {\n  if (templateName === BuiltinTemplate.monorepo) {\n    return 'Vite+ monorepo';\n  }\n  if (templateName === BuiltinTemplate.generator) {\n    return 'generator scaffold';\n  }\n  if (templateName === BuiltinTemplate.library) {\n    return 'TypeScript library';\n  }\n\n  const selectedTemplate = getTemplateOption(templateArgs);\n  if (selectedTemplate) {\n    return formatTemplateName(selectedTemplate);\n  }\n\n  if (templateName === BuiltinTemplate.application) {\n    return 'Vite application';\n  }\n\n  return undefined;\n}\n\nfunction getTemplateOption(args: string[]) {\n  for (let index = 0; index < args.length; index++) {\n    const arg = args[index];\n    if (arg === '--template' || arg === '-t') {\n      return args[index + 1];\n    }\n    if (arg.startsWith('--template=')) {\n      return arg.slice('--template='.length);\n    }\n  }\n  return undefined;\n}\n\nfunction hasExplicitTargetDir(args: string[]) {\n  return args[0] !== undefined && !args[0].startsWith('-');\n}\n\nfunction formatTemplateName(templateName: string) {\n  const templateAliases: Record<string, string> = {\n    lit: 'Lit',\n    preact: 'Preact',\n    react: 'React',\n    'react-router': 'React Router',\n    solid: 'Solid',\n    svelte: 'Svelte',\n    vanilla: 'Vanilla',\n    vue: 'Vue',\n  };\n  const isTypeScript = templateName.endsWith('-ts');\n  const baseName = isTypeScript ? templateName.slice(0, -3) : templateName;\n  const frameworkName =\n    templateAliases[baseName] ??\n    baseName\n      .split(/[-_]/)\n      .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))\n      .join(' ');\n\n  return `${frameworkName} + ${isTypeScript ? 'TypeScript' : 'JavaScript'}`;\n}\n\nfunction formatDuration(durationMs: number) {\n  if (durationMs < 1000) {\n    return `${Math.max(1, durationMs)}ms`;\n  }\n  const durationSeconds = durationMs / 1000;\n  if (durationSeconds < 10) {\n    return `${durationSeconds.toFixed(1)}s`;\n  }\n  return `${Math.round(durationSeconds)}s`;\n}\n\nfunction getNextCommand(projectDir: string, command: string) {\n  if (!projectDir || projectDir === '.') {\n    return command;\n  }\n  return `cd ${projectDir} && ${command}`;\n}\n\nfunction showCreateSummary(options: {\n  description?: string;\n  installSummary?: CommandRunSummary;\n  nextCommand: string;\n  packageManager: string;\n  packageManagerVersion: string;\n  projectDir: string;\n}) {\n  const {\n    description,\n    installSummary,\n    nextCommand,\n    packageManager,\n    packageManagerVersion,\n    projectDir,\n  } = options;\n\n  log(\n    `${styleText('magenta', '◇')} Scaffolded ${accent(projectDir)}${\n      description ? ` with ${description}` : ''\n    }`,\n  );\n  log(\n    `${styleText('gray', '•')} Node ${process.versions.node}  ${packageManager} ${packageManagerVersion}`,\n  );\n  if (installSummary?.status === 'installed') {\n    log(\n      `${styleText('green', '✓')} Dependencies installed in ${formatDuration(\n        installSummary.durationMs,\n      )}`,\n    );\n  }\n  log(`${styleText('blue', '→')} Next: ${accent(nextCommand)}`);\n}\n\nasync function main() {\n  const { templateName, options, templateArgs } = parseArgs();\n  let compactOutput = !options.verbose;\n\n  // #region Handle help flag\n  if (options.help) {\n    log(vitePlusHeader() + '\\n');\n    log(helpMessage);\n    return;\n  }\n  // #endregion\n\n  // #region Handle list flag\n  if (options.list) {\n    await showAvailableTemplates();\n    return;\n  }\n  // #endregion\n\n  // #region Handle required arguments\n  if (!templateName && !options.interactive) {\n    console.error(`\nA template name is required when running in non-interactive mode\n\nUsage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]\n\nExample:\n  ${muted('# Create a new application in non-interactive mode with a custom target directory')}\n  vp create vite:application --no-interactive --directory=apps/my-app\n\nUse \\`vp create --list\\` to list all available templates, or run \\`vp create --help\\` for more information.\n`);\n    process.exit(1);\n  }\n  // #endregion\n\n  // #region Prepare Stage\n  if (options.interactive) {\n    prompts.intro(vitePlusHeader());\n  }\n\n  // check --directory option is valid\n  let targetDir = '';\n  let packageName = '';\n  if (options.directory) {\n    const formatted = formatTargetDir(options.directory);\n    if (formatted.error) {\n      prompts.log.error(formatted.error);\n      cancelAndExit('The --directory option is invalid', 1);\n    }\n    targetDir = formatted.directory;\n    packageName = formatted.packageName;\n  }\n\n  const cwd = process.cwd();\n  const workspaceInfoOptional = await detectWorkspace(cwd);\n  const isMonorepo = workspaceInfoOptional.isMonorepo;\n\n  // For non-monorepo, always use cwd as rootDir.\n  // detectWorkspace walks up to find the nearest package.json, but for `vp create`\n  // in standalone mode, the project should be created relative to where the user is.\n  if (!isMonorepo) {\n    workspaceInfoOptional.rootDir = cwd;\n  }\n  const cwdRelativeToRoot =\n    isMonorepo && workspaceInfoOptional.rootDir !== cwd\n      ? displayRelative(cwd, workspaceInfoOptional.rootDir)\n      : '';\n  const isInSubdirectory = cwdRelativeToRoot !== '';\n  const cwdUnderParentDir = isInSubdirectory\n    ? workspaceInfoOptional.parentDirs.some(\n        (dir) => cwdRelativeToRoot === dir || cwdRelativeToRoot.startsWith(`${dir}/`),\n      )\n    : true;\n  const shouldOfferCwdOption = isInSubdirectory && !cwdUnderParentDir;\n\n  // Interactive mode: prompt for template if not provided\n  let selectedTemplateName = templateName as string;\n  let selectedTemplateArgs = [...templateArgs];\n  let selectedAgentTargetPaths: string[] | undefined;\n  let selectedEditor: Awaited<ReturnType<typeof selectEditor>>;\n  let selectedParentDir: string | undefined;\n  let remoteTargetDir: string | undefined;\n  let shouldSetupHooks = false;\n\n  if (!selectedTemplateName) {\n    const template = await prompts.select({\n      message: '',\n      options: getInitialTemplateOptions(isMonorepo),\n    });\n\n    if (prompts.isCancel(template)) {\n      cancelAndExit();\n    }\n\n    selectedTemplateName = template;\n  }\n\n  const isBuiltinTemplate = selectedTemplateName.startsWith('vite:');\n\n  // Remote templates (e.g., @tanstack/create-start, custom templates) run their own\n  // interactive CLI, so verbose mode is needed to show their output.\n  if (!isBuiltinTemplate) {\n    compactOutput = false;\n  }\n\n  if (targetDir && !isBuiltinTemplate) {\n    cancelAndExit('The --directory option is only available for builtin templates', 1);\n  }\n  if (selectedTemplateName === BuiltinTemplate.monorepo && isMonorepo) {\n    prompts.log.info(\n      'You are already in a monorepo workspace.\\nUse a different template or run this command outside the monorepo',\n    );\n    cancelAndExit('Cannot create a monorepo inside an existing monorepo', 1);\n  }\n  if (selectedTemplateName === BuiltinTemplate.generator && !isMonorepo) {\n    prompts.log.info(\n      'The vite:generator template requires a monorepo workspace.\\nRun this command inside a Vite+ monorepo, or create one first with `vp create vite:monorepo`',\n    );\n    cancelAndExit('Cannot create a generator outside a monorepo', 1);\n  }\n\n  if (isInSubdirectory && !compactOutput) {\n    prompts.log.info(`Detected monorepo root at ${accent(workspaceInfoOptional.rootDir)}`);\n  }\n\n  if (isMonorepo && options.interactive && !targetDir) {\n    let parentDir: string | undefined;\n    const hasParentDirs = workspaceInfoOptional.parentDirs.length > 0;\n\n    if (hasParentDirs || isInSubdirectory) {\n      const dirOptions: { label: string; value: string; hint: string }[] =\n        workspaceInfoOptional.parentDirs.map((dir) => ({\n          label: `${dir}/`,\n          value: dir,\n          hint: '',\n        }));\n\n      if (shouldOfferCwdOption) {\n        dirOptions.push({\n          label: `${cwdRelativeToRoot}/ (current directory)`,\n          value: cwdRelativeToRoot,\n          hint: '',\n        });\n      }\n\n      dirOptions.push({\n        label: 'other directory',\n        value: 'other',\n        hint: 'Enter a custom target directory',\n      });\n\n      const defaultParentDir = shouldOfferCwdOption\n        ? cwdRelativeToRoot\n        : (inferParentDir(selectedTemplateName, workspaceInfoOptional) ??\n          workspaceInfoOptional.parentDirs[0]);\n\n      const selected = await prompts.select({\n        message: 'Where should the new package be added to the monorepo:',\n        options: dirOptions,\n        initialValue: defaultParentDir,\n      });\n\n      if (prompts.isCancel(selected)) {\n        cancelAndExit();\n      }\n\n      if (selected !== 'other') {\n        parentDir = selected;\n      }\n    }\n\n    if (!parentDir) {\n      const customTargetDir = await prompts.text({\n        message: 'Where should the new package be added to the monorepo:',\n        placeholder: 'e.g., packages/',\n        validate: (value) => {\n          return value ? formatTargetDir(value).error : 'Target directory is required';\n        },\n      });\n\n      if (prompts.isCancel(customTargetDir)) {\n        cancelAndExit();\n      }\n\n      parentDir = customTargetDir;\n    }\n\n    selectedParentDir = parentDir;\n  }\n  if (isMonorepo && !options.interactive && !targetDir) {\n    if (isInSubdirectory && !compactOutput) {\n      prompts.log.info(`Use ${accent('--directory')} to specify a different target location.`);\n    }\n    const inferredParentDir =\n      inferParentDir(selectedTemplateName, workspaceInfoOptional) ??\n      workspaceInfoOptional.parentDirs[0];\n    selectedParentDir = inferredParentDir;\n  }\n\n  if (isGitHubUrl(selectedTemplateName)) {\n    if (hasExplicitTargetDir(selectedTemplateArgs)) {\n      remoteTargetDir = selectedTemplateArgs[0];\n    } else {\n      const inferredTargetDir = inferGitHubRepoName(selectedTemplateName) ?? 'template';\n      const remoteTargetBaseDir = selectedParentDir\n        ? path.join(workspaceInfoOptional.rootDir, selectedParentDir)\n        : workspaceInfoOptional.rootDir;\n      const defaultTargetDir = suggestAvailableTargetDir(inferredTargetDir, remoteTargetBaseDir);\n      if (defaultTargetDir !== inferredTargetDir && options.interactive) {\n        prompts.log.info(\n          `  Target directory \"${inferredTargetDir}\" already exists. Suggested: ${accent(defaultTargetDir)}`,\n        );\n      }\n      remoteTargetDir = await promptTargetDir(defaultTargetDir, options.interactive, {\n        cwd: remoteTargetBaseDir,\n      });\n      selectedTemplateArgs = [remoteTargetDir, ...selectedTemplateArgs];\n    }\n  }\n\n  if (isBuiltinTemplate && (!targetDir || targetDir === '.')) {\n    if (targetDir === '.') {\n      // Current directory: auto-derive package name from cwd, no prompt\n      const fallbackName =\n        selectedTemplateName === BuiltinTemplate.monorepo\n          ? 'vite-plus-monorepo'\n          : `vite-plus-${selectedTemplateName.split(':')[1]}`;\n      packageName = deriveDefaultPackageName(\n        cwd,\n        workspaceInfoOptional.monorepoScope,\n        fallbackName,\n      );\n      if (isMonorepo) {\n        if (!cwdRelativeToRoot) {\n          // At monorepo root: scaffolding here would overwrite the entire workspace\n          cancelAndExit(\n            'Cannot scaffold into the monorepo root directory. Use --directory to specify a target directory',\n            1,\n          );\n        }\n        // Check if cwd is inside an existing workspace package\n        const enclosingPackage = workspaceInfoOptional.packages.find(\n          (pkg) => cwdRelativeToRoot === pkg.path || cwdRelativeToRoot.startsWith(`${pkg.path}/`),\n        );\n        if (enclosingPackage) {\n          cancelAndExit(\n            `Cannot scaffold inside existing package \"${enclosingPackage.name}\" (${enclosingPackage.path}). Use --directory to specify a different location`,\n            1,\n          );\n        }\n        // Resolve '.' to the path relative to rootDir\n        // so that scaffolding happens in cwd, not at the workspace root\n        targetDir = cwdRelativeToRoot;\n      }\n      prompts.log.info(`Using package name: ${accent(packageName)}`);\n    } else if (selectedTemplateName === BuiltinTemplate.monorepo) {\n      const selected = await promptPackageNameAndTargetDir(\n        getRandomProjectName({ fallbackName: 'vite-plus-monorepo' }),\n        options.interactive,\n      );\n      packageName = selected.packageName;\n      targetDir = selected.targetDir;\n    } else {\n      const defaultPackageName = getRandomProjectName({\n        scope: workspaceInfoOptional.monorepoScope,\n        fallbackName: `vite-plus-${selectedTemplateName.split(':')[1]}`,\n      });\n      const selected = await promptPackageNameAndTargetDir(defaultPackageName, options.interactive);\n      packageName = selected.packageName;\n      targetDir = selectedParentDir\n        ? path.join(selectedParentDir, selected.targetDir).split(path.sep).join('/')\n        : selected.targetDir;\n    }\n  }\n\n  // Prompt for package manager or use default\n  const packageManager =\n    workspaceInfoOptional.packageManager ??\n    (await selectPackageManager(options.interactive, compactOutput));\n  const shouldSilencePackageManagerInstallLog =\n    compactOutput || (isMonorepo && workspaceInfoOptional.packageManager !== undefined);\n  // ensure the package manager is installed by vite-plus\n  const downloadResult = await downloadPackageManager(\n    packageManager,\n    workspaceInfoOptional.packageManagerVersion,\n    options.interactive,\n    shouldSilencePackageManagerInstallLog,\n  );\n  const workspaceInfo: WorkspaceInfo = {\n    ...workspaceInfoOptional,\n    packageManager,\n    downloadPackageManager: downloadResult,\n  };\n\n  const existingAgentTargetPaths =\n    options.agent !== undefined || !options.interactive\n      ? undefined\n      : detectExistingAgentTargetPaths(workspaceInfoOptional.rootDir);\n  selectedAgentTargetPaths =\n    existingAgentTargetPaths !== undefined\n      ? existingAgentTargetPaths\n      : await selectAgentTargetPaths({\n          interactive: options.interactive,\n          agent: options.agent,\n          onCancel: () => cancelAndExit(),\n        });\n\n  const existingEditor =\n    options.editor || !options.interactive\n      ? undefined\n      : detectExistingEditor(workspaceInfoOptional.rootDir);\n  selectedEditor =\n    existingEditor ??\n    (await selectEditor({\n      interactive: options.interactive,\n      editor: options.editor,\n      onCancel: () => cancelAndExit(),\n    }));\n\n  if (!isMonorepo) {\n    shouldSetupHooks = await promptGitHooks(options);\n  }\n\n  const createProgress =\n    options.interactive && compactOutput ? prompts.spinner({ indicator: 'timer' }) : undefined;\n  let createProgressStarted = false;\n  let createProgressMessage = 'Scaffolding project';\n  const updateCreateProgress = (message: string) => {\n    createProgressMessage = message;\n    if (!createProgress) {\n      return;\n    }\n    if (createProgressStarted) {\n      createProgress.message(message);\n      return;\n    }\n    createProgress.start(message);\n    createProgressStarted = true;\n  };\n  const clearCreateProgress = () => {\n    if (createProgress && createProgressStarted) {\n      createProgress.clear();\n      createProgressStarted = false;\n    }\n  };\n  const failCreateProgress = (message: string) => {\n    if (createProgress && createProgressStarted) {\n      createProgress.error(message);\n      createProgressStarted = false;\n    }\n  };\n  const pauseCreateProgress = () => {\n    if (createProgress && createProgressStarted) {\n      createProgress.pause();\n      createProgressStarted = false;\n    }\n  };\n  const resumeCreateProgress = () => {\n    if (createProgress && !createProgressStarted) {\n      createProgress.resume(createProgressMessage);\n      createProgressStarted = true;\n    }\n  };\n  updateCreateProgress('Scaffolding project');\n\n  // Discover template\n  const templateInfo = discoverTemplate(\n    selectedTemplateName,\n    selectedTemplateArgs,\n    workspaceInfo,\n    options.interactive,\n  );\n\n  if (selectedParentDir) {\n    templateInfo.parentDir = selectedParentDir;\n  }\n\n  // only for builtin templates\n  if (targetDir) {\n    // reset auto detect parent directory\n    templateInfo.parentDir = undefined;\n  }\n\n  if (remoteTargetDir) {\n    const projectDir = templateInfo.parentDir\n      ? path.join(templateInfo.parentDir, remoteTargetDir)\n      : remoteTargetDir;\n    pauseCreateProgress();\n    await checkProjectDirExists(path.join(workspaceInfo.rootDir, projectDir), options.interactive);\n    resumeCreateProgress();\n  }\n\n  // #endregion\n\n  // #region Handle monorepo template\n  if (templateInfo.command === BuiltinTemplate.monorepo) {\n    updateCreateProgress('Creating monorepo');\n    await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive);\n    const result = await executeMonorepoTemplate(\n      workspaceInfo,\n      { ...templateInfo, packageName, targetDir },\n      options.interactive,\n      { silent: compactOutput },\n    );\n    const { projectDir } = result;\n    if (result.exitCode !== 0 || !projectDir) {\n      failCreateProgress('Scaffolding failed');\n      cancelAndExit(`Failed to create monorepo, exit code: ${result.exitCode}`, result.exitCode);\n    }\n\n    // rewrite monorepo to add vite-plus dependencies\n    const fullPath = path.join(workspaceInfo.rootDir, projectDir);\n    updateCreateProgress('Writing agent instructions');\n    pauseCreateProgress();\n    await writeAgentInstructions({\n      projectRoot: fullPath,\n      targetPaths: selectedAgentTargetPaths,\n      interactive: options.interactive,\n      silent: compactOutput,\n    });\n    resumeCreateProgress();\n    updateCreateProgress('Writing editor configs');\n    pauseCreateProgress();\n    await writeEditorConfigs({\n      projectRoot: fullPath,\n      editorId: selectedEditor,\n      interactive: options.interactive,\n      silent: compactOutput,\n    });\n    resumeCreateProgress();\n    workspaceInfo.rootDir = fullPath;\n    updateCreateProgress('Integrating monorepo');\n    rewriteMonorepo(workspaceInfo, undefined, compactOutput);\n    if (shouldSetupHooks) {\n      installGitHooks(fullPath, compactOutput);\n    }\n    updateCreateProgress('Installing dependencies');\n    const installSummary = await runViteInstall(fullPath, options.interactive, undefined, {\n      silent: compactOutput,\n    });\n    updateCreateProgress('Formatting code');\n    await runViteFmt(fullPath, options.interactive, undefined, { silent: compactOutput });\n    clearCreateProgress();\n    showCreateSummary({\n      description: describeScaffold(selectedTemplateName, selectedTemplateArgs),\n      installSummary,\n      nextCommand: getNextCommand(projectDir, `vp dev ${InitialMonorepoAppDir}`),\n      packageManager: workspaceInfo.packageManager,\n      packageManagerVersion: workspaceInfo.downloadPackageManager.version,\n      projectDir,\n    });\n    return;\n  }\n  // #endregion\n\n  // #region Handle single project template\n\n  let result: ExecutionResult;\n  if (templateInfo.type === TemplateType.builtin) {\n    // prompt for package name if not provided\n    if (!targetDir) {\n      const defaultPackageName = getRandomProjectName({\n        scope: workspaceInfo.monorepoScope,\n        fallbackName: `vite-plus-${templateInfo.command.split(':')[1]}`,\n      });\n      const selected = await promptPackageNameAndTargetDir(defaultPackageName, options.interactive);\n      packageName = selected.packageName;\n      targetDir = templateInfo.parentDir\n        ? path.join(templateInfo.parentDir, selected.targetDir).split(path.sep).join('/')\n        : selected.targetDir;\n    }\n    pauseCreateProgress();\n    await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive);\n    resumeCreateProgress();\n    updateCreateProgress('Generating project');\n    result = await executeBuiltinTemplate(\n      workspaceInfo,\n      {\n        ...templateInfo,\n        packageName,\n        targetDir,\n      },\n      { silent: compactOutput },\n    );\n  } else {\n    updateCreateProgress('Generating project');\n    result = await executeRemoteTemplate(workspaceInfo, templateInfo, { silent: compactOutput });\n  }\n\n  if (result.exitCode !== 0) {\n    failCreateProgress('Scaffolding failed');\n    process.exit(result.exitCode);\n  }\n  const projectDir = result.projectDir;\n  if (!projectDir) {\n    clearCreateProgress();\n    process.exit(0);\n  }\n\n  const fullPath = path.join(workspaceInfo.rootDir, projectDir);\n  const agentInstructionsRoot = isMonorepo ? workspaceInfo.rootDir : fullPath;\n  updateCreateProgress('Writing agent instructions');\n  pauseCreateProgress();\n  await writeAgentInstructions({\n    projectRoot: agentInstructionsRoot,\n    targetPaths: selectedAgentTargetPaths,\n    interactive: options.interactive,\n    silent: compactOutput,\n  });\n  resumeCreateProgress();\n  updateCreateProgress('Writing editor configs');\n  pauseCreateProgress();\n  await writeEditorConfigs({\n    projectRoot: fullPath,\n    editorId: selectedEditor,\n    interactive: options.interactive,\n    silent: compactOutput,\n  });\n  resumeCreateProgress();\n\n  let installSummary: CommandRunSummary | undefined;\n  if (isMonorepo) {\n    if (!compactOutput) {\n      prompts.log.step('Monorepo integration...');\n    }\n    updateCreateProgress('Integrating into monorepo');\n    rewriteMonorepoProject(fullPath, workspaceInfo.packageManager, undefined, compactOutput);\n\n    if (workspaceInfo.packages.length > 0) {\n      if (options.interactive) {\n        pauseCreateProgress();\n        const selectedDepTypeOptions = await prompts.multiselect({\n          message: `Add workspace dependencies to ${accent(projectDir)}?`,\n          options: [\n            {\n              value: DependencyType.dependencies,\n            },\n            {\n              value: DependencyType.devDependencies,\n            },\n            {\n              value: DependencyType.peerDependencies,\n            },\n            {\n              value: DependencyType.optionalDependencies,\n            },\n          ],\n          required: false,\n        });\n\n        let selectedDepTypes: DependencyType[] = [];\n        if (!prompts.isCancel(selectedDepTypeOptions)) {\n          selectedDepTypes = selectedDepTypeOptions;\n        }\n\n        for (const selectedDepType of selectedDepTypes) {\n          const selected = await prompts.multiselect({\n            message: `Which packages should be added as ${selectedDepType} to ${success(\n              projectDir,\n            )}?`,\n            // FIXME: ignore itself as dependency\n            options: workspaceInfo.packages.map((pkg) => ({\n              value: pkg.name,\n              label: pkg.path,\n            })),\n            required: false,\n          });\n          let selectedDeps: string[] = [];\n          if (!prompts.isCancel(selected)) {\n            selectedDeps = selected;\n          }\n\n          if (selectedDeps.length > 0) {\n            // FIXME: should use `vp add` command instead\n            updatePackageJsonWithDeps(\n              workspaceInfo.rootDir,\n              projectDir,\n              selectedDeps,\n              selectedDepType,\n            );\n          }\n        }\n        resumeCreateProgress();\n      }\n    }\n\n    updateWorkspaceConfig(projectDir, workspaceInfo);\n    updateCreateProgress('Installing dependencies');\n    installSummary = await runViteInstall(workspaceInfo.rootDir, options.interactive, undefined, {\n      silent: compactOutput,\n    });\n    updateCreateProgress('Formatting code');\n    await runViteFmt(workspaceInfo.rootDir, options.interactive, [projectDir], {\n      silent: compactOutput,\n    });\n  } else {\n    updateCreateProgress('Applying Vite+ project setup');\n    rewriteStandaloneProject(fullPath, workspaceInfo, undefined, compactOutput);\n    if (shouldSetupHooks) {\n      installGitHooks(fullPath, compactOutput);\n    }\n    updateCreateProgress('Installing dependencies');\n    installSummary = await runViteInstall(fullPath, options.interactive, undefined, {\n      silent: compactOutput,\n    });\n    updateCreateProgress('Formatting code');\n    await runViteFmt(fullPath, options.interactive, undefined, { silent: compactOutput });\n  }\n\n  clearCreateProgress();\n  showCreateSummary({\n    description: describeScaffold(selectedTemplateName, selectedTemplateArgs),\n    installSummary,\n    nextCommand: isMonorepo\n      ? `vp dev ${projectDir}`\n      : getNextCommand(\n          projectDir,\n          selectedTemplateName === BuiltinTemplate.library ? 'vp run dev' : 'vp dev',\n        ),\n    packageManager: workspaceInfo.packageManager,\n    packageManagerVersion: workspaceInfo.downloadPackageManager.version,\n    projectDir,\n  });\n  // #endregion\n}\n\nasync function showAvailableTemplates() {\n  log(vitePlusHeader() + '\\n');\n  log(listTemplatesMessage);\n}\n\nmain().catch((err) => {\n  prompts.log.error(err.message);\n  console.error(err);\n  cancelAndExit(`Failed to generate code: ${err.message}`, 1);\n});\n"
  },
  {
    "path": "packages/cli/src/create/command.ts",
    "content": "import fs from 'node:fs/promises';\nimport path from 'node:path';\n\nimport spawn from 'cross-spawn';\n\nimport { runCommand as runCommandWithFspy } from '../../binding/index.js';\nimport type { WorkspaceInfo } from '../types/index.js';\n\nexport interface ExecutionResult {\n  exitCode: number;\n  projectDir?: string;\n}\n\nexport interface RunCommandOptions {\n  command: string;\n  args: string[];\n  cwd: string;\n  envs: NodeJS.ProcessEnv;\n}\n\n// Run a command and detect the project directory\nexport async function runCommandAndDetectProjectDir(\n  options: RunCommandOptions,\n  parentDir?: string,\n): Promise<ExecutionResult> {\n  const cwd = parentDir ? path.join(options.cwd, parentDir) : options.cwd;\n  const existingDirs = new Set<string>();\n  if (parentDir) {\n    await fs.mkdir(cwd, { recursive: true });\n    // Get existing subdirectories before running the command\n    const entries = await fs.readdir(cwd, { withFileTypes: true });\n    for (const entry of entries) {\n      if (entry.isDirectory()) {\n        existingDirs.add(entry.name);\n      }\n    }\n  }\n\n  const result = await runCommandWithFspy({\n    binName: options.command,\n    args: options.args,\n    envs: options.envs as Record<string, string>,\n    cwd,\n  });\n\n  // Detect project directory from path accesses\n  // Find the closest directory containing package.json relative to cwd\n  let projectDir: string | undefined;\n  let minDepth = Infinity;\n\n  for (const [filePath, pathAccess] of Object.entries(result.pathAccesses)) {\n    // Look for package.json writes\n    if (\n      pathAccess.write &&\n      filePath.endsWith('package.json') &&\n      !filePath.includes('node_modules')\n    ) {\n      // Extract directory from package.json path\n      const dir = path.dirname(filePath);\n\n      // Skip if it's the current directory\n      if (dir === '.' || dir === '') {\n        continue;\n      }\n      // Skip if this is an existing directory (created before the command ran)\n      if (existingDirs.has(dir)) {\n        continue;\n      }\n\n      // Calculate depth (number of path segments)\n      const depth = dir.split(path.sep).length;\n\n      // Keep the closest (shallowest) directory\n      if (depth < minDepth) {\n        minDepth = depth;\n        projectDir = dir;\n      }\n    }\n  }\n\n  // If parentDir is provided, join it with the project directory\n  if (parentDir && projectDir) {\n    projectDir = path.join(parentDir, projectDir);\n  }\n\n  return {\n    exitCode: result.exitCode,\n    projectDir,\n  };\n}\n\nexport interface RunCommandResult extends ExecutionResult {\n  stdout: Buffer;\n  stderr: Buffer;\n}\n\nexport async function runCommandSilently(options: RunCommandOptions): Promise<RunCommandResult> {\n  const child = spawn(options.command, options.args, {\n    stdio: 'pipe',\n    cwd: options.cwd,\n    env: options.envs,\n  });\n  const promise = new Promise<RunCommandResult>((resolve, reject) => {\n    const stdout: Buffer[] = [];\n    const stderr: Buffer[] = [];\n    child.stdout?.on('data', (data) => {\n      stdout.push(data);\n    });\n    child.stderr?.on('data', (data) => {\n      stderr.push(data);\n    });\n    child.on('close', (code) => {\n      resolve({\n        exitCode: code ?? 0,\n        stdout: Buffer.concat(stdout),\n        stderr: Buffer.concat(stderr),\n      });\n    });\n    child.on('error', (err) => {\n      reject(err);\n    });\n  });\n  return await promise;\n}\n\nexport async function runCommand(options: RunCommandOptions): Promise<ExecutionResult> {\n  const child = spawn(options.command, options.args, {\n    stdio: 'inherit',\n    cwd: options.cwd,\n    env: options.envs,\n  });\n  const promise = new Promise<ExecutionResult>((resolve, reject) => {\n    child.on('close', (code) => {\n      resolve({ exitCode: code ?? 0 });\n    });\n    child.on('error', (err) => {\n      reject(err);\n    });\n  });\n  return await promise;\n}\n\n// Get the package runner command for each package manager\nexport function getPackageRunner(workspaceInfo: WorkspaceInfo) {\n  switch (workspaceInfo.packageManager) {\n    case 'pnpm':\n      return {\n        command: 'pnpm',\n        args: ['dlx'],\n      };\n    case 'yarn':\n      return {\n        command: 'yarn',\n        args: ['dlx'],\n      };\n    case 'npm':\n    default:\n      return { command: 'npx', args: [] };\n  }\n}\n\n// TODO: will use `vp dlx` instead, see https://github.com/voidzero-dev/vite-task/issues/27\nexport function formatDlxCommand(\n  packageName: string,\n  args: string[],\n  workspaceInfo: WorkspaceInfo,\n) {\n  const runner = getPackageRunner(workspaceInfo);\n  const dlxArgs = runner.command === 'npm' ? ['--', ...args] : args;\n  return {\n    command: runner.command,\n    args: [...runner.args, packageName, ...dlxArgs],\n  };\n}\n\nexport function prependToPathToEnvs(extraPath: string, envs: NodeJS.ProcessEnv) {\n  const delimiter = path.delimiter;\n  const pathKey = Object.keys(envs).find((key) => key.toLowerCase() === 'path') ?? 'PATH';\n\n  const current = envs[pathKey] ?? '';\n\n  // avoid duplicate\n  const parts = current.split(delimiter).filter(Boolean);\n  if (!parts.includes(extraPath)) {\n    envs[pathKey] = extraPath + (current ? delimiter + current : '');\n  }\n  return envs;\n}\n"
  },
  {
    "path": "packages/cli/src/create/discovery.ts",
    "content": "import path from 'node:path';\n\nimport type { WorkspaceInfo, WorkspaceInfoOptional } from '../types/index.js';\nimport { readJsonFile } from '../utils/json.js';\nimport { prependToPathToEnvs } from './command.js';\nimport { BuiltinTemplate, type TemplateInfo, TemplateType } from './templates/types.js';\n\n// Check if template name is a GitHub URL\nexport function isGitHubUrl(templateName: string): boolean {\n  return (\n    templateName.startsWith('https://github.com/') ||\n    templateName.startsWith('github:') ||\n    templateName.includes('github.com/')\n  );\n}\n\n// Convert GitHub URL to degit format\nexport function parseGitHubUrl(url: string): string | null {\n  // github:user/repo → user/repo\n  if (url.startsWith('github:')) {\n    return url.slice(7);\n  }\n\n  // https://github.com/user/repo → user/repo\n  const match = url.match(/github\\.com\\/([^/]+\\/[^/]+)/);\n  if (match) {\n    return match[1].replace(/\\.git$/, '');\n  }\n\n  return null;\n}\n\nexport function inferGitHubRepoName(templateName: string): string | null {\n  const degitPath = parseGitHubUrl(templateName);\n  if (!degitPath) {\n    return null;\n  }\n\n  const repoName = degitPath.split('/').pop();\n  return repoName || null;\n}\n\n// Discover and identify a template\nexport function discoverTemplate(\n  templateName: string,\n  templateArgs: string[],\n  workspaceInfo: WorkspaceInfo,\n  interactive?: boolean,\n): TemplateInfo {\n  const envs = prependToPathToEnvs(workspaceInfo.downloadPackageManager.binPrefix, {\n    ...process.env,\n  });\n  const parentDir = inferParentDir(templateName, workspaceInfo);\n  // Check for built-in templates\n  if (templateName.startsWith('vite:')) {\n    return {\n      command: templateName,\n      args: [...templateArgs],\n      envs,\n      type: TemplateType.builtin,\n      parentDir,\n      interactive,\n    };\n  }\n\n  // Check for GitHub URLs\n  if (isGitHubUrl(templateName)) {\n    const degitPath = parseGitHubUrl(templateName);\n    if (degitPath) {\n      return {\n        command: 'degit',\n        args: [degitPath, ...templateArgs],\n        envs,\n        type: TemplateType.remote,\n        parentDir,\n        interactive,\n      };\n    }\n  }\n\n  // Check for local package\n  const localPackage = workspaceInfo.packages.find((pkg) => pkg.name === templateName);\n  if (localPackage) {\n    const localPackagePath = path.join(workspaceInfo.rootDir, localPackage.path);\n    const packageJsonPath = path.join(localPackagePath, 'package.json');\n    const pkg = readJsonFile<{\n      dependencies?: Record<string, string>;\n      keywords?: string[];\n      bin?: Record<string, string> | string;\n    }>(packageJsonPath);\n    let binPath = '';\n    if (pkg.bin) {\n      if (typeof pkg.bin === 'string') {\n        binPath = path.join(localPackagePath, pkg.bin);\n      } else {\n        const binName = Object.keys(pkg.bin)[0];\n        binPath = path.join(localPackagePath, pkg.bin[binName]);\n      }\n    }\n    const args = [binPath, ...templateArgs];\n    let type: TemplateType = TemplateType.remote;\n    if (pkg.keywords?.includes('bingo-template') || !!pkg.dependencies?.bingo) {\n      type = TemplateType.bingo;\n      // add `--skip-requests` by default for bingo templates\n      args.push('--skip-requests');\n    }\n    if (binPath) {\n      return {\n        command: 'node',\n        args,\n        envs,\n        type,\n        parentDir,\n        interactive,\n      };\n    }\n  }\n\n  const expandedName = expandCreateShorthand(templateName);\n  return {\n    command: expandedName,\n    args: [...templateArgs],\n    envs,\n    type: TemplateType.remote,\n    parentDir,\n    interactive,\n  };\n}\n\n/**\n * Expand shorthand template names to their full `create-*` package names.\n *\n * This follows the same convention as `npm create` / `pnpm create`:\n * - `vite` → `create-vite`\n * - `vite@latest` → `create-vite@latest`\n * - `@tanstack/start` → `@tanstack/create-start`\n * - `@tanstack/start@latest` → `@tanstack/create-start@latest`\n *\n * Special cases for packages where the convention doesn't work:\n * - `nitro` → `create-nitro-app` (create-nitro is abandoned)\n *\n * Skips expansion for:\n * - Builtin templates (`vite:*`)\n * - GitHub URLs\n * - Local paths (`./`, `../`, `/`)\n * - Names already starting with `create-` (or `@scope/create-`)\n */\nexport function expandCreateShorthand(templateName: string): string {\n  // Skip builtins (vite:monorepo, vite:application, etc.)\n  if (templateName.includes(':')) {\n    return templateName;\n  }\n\n  // Skip GitHub URLs\n  if (isGitHubUrl(templateName)) {\n    return templateName;\n  }\n\n  // Skip local paths\n  if (\n    templateName.startsWith('./') ||\n    templateName.startsWith('../') ||\n    templateName.startsWith('/')\n  ) {\n    return templateName;\n  }\n\n  // Scoped package: @scope/name[@version]\n  if (templateName.startsWith('@')) {\n    const slashIndex = templateName.indexOf('/');\n    if (slashIndex === -1) {\n      // @scope or @scope@version → @scope/create[@version]\n      const atIndex = templateName.indexOf('@', 1);\n      const scope = atIndex === -1 ? templateName : templateName.slice(0, atIndex);\n      const version = atIndex === -1 ? '' : templateName.slice(atIndex);\n      return `${scope}/create${version}`;\n    }\n    const scope = templateName.slice(0, slashIndex);\n    const rest = templateName.slice(slashIndex + 1);\n\n    // Split name and version: name@version\n    const atIndex = rest.indexOf('@');\n    const name = atIndex === -1 ? rest : rest.slice(0, atIndex);\n    const version = atIndex === -1 ? '' : rest.slice(atIndex);\n\n    if (name.startsWith('create-')) {\n      return templateName;\n    }\n    return `${scope}/create-${name}${version}`;\n  }\n\n  // Unscoped package: name[@version]\n  const atIndex = templateName.indexOf('@');\n  const name = atIndex === -1 ? templateName : templateName.slice(0, atIndex);\n  const version = atIndex === -1 ? '' : templateName.slice(atIndex);\n\n  if (name.startsWith('create-')) {\n    return templateName;\n  }\n\n  // Special cases where the default convention doesn't apply\n  if (name === 'nitro') {\n    return `create-nitro-app${version}`;\n  }\n  if (name === 'svelte') {\n    return `sv${version}`;\n  }\n\n  return `create-${name}${version}`;\n}\n\n// Infer the parent directory of the generated package based on the template name\nexport function inferParentDir(\n  templateName: string,\n  workspaceInfo: WorkspaceInfoOptional,\n): string | undefined {\n  if (workspaceInfo.parentDirs.length === 0) {\n    return;\n  }\n  // apps/applications by default\n  let rule = /app/i;\n  if (templateName === BuiltinTemplate.library) {\n    // libraries/packages/components\n    rule = /lib|component|package/i;\n  } else if (templateName === BuiltinTemplate.generator) {\n    // generators/tools\n    rule = /generator|tool/i;\n  }\n  for (const parentDir of workspaceInfo.parentDirs) {\n    if (rule.test(parentDir)) {\n      return parentDir;\n    }\n  }\n  return;\n}\n"
  },
  {
    "path": "packages/cli/src/create/initial-template-options.ts",
    "content": "import { BuiltinTemplate } from './templates/types.js';\n\nexport interface InitialTemplateOption {\n  label: string;\n  value: string;\n  hint: string;\n}\n\nexport function getInitialTemplateOptions(isMonorepo: boolean): InitialTemplateOption[] {\n  return [\n    ...(!isMonorepo\n      ? [\n          {\n            label: 'Vite+ Monorepo',\n            value: BuiltinTemplate.monorepo,\n            hint: 'Create a new Vite+ monorepo project',\n          },\n        ]\n      : []),\n    {\n      label: 'Vite+ Application',\n      value: BuiltinTemplate.application,\n      hint: 'Create vite applications',\n    },\n    {\n      label: 'Vite+ Library',\n      value: BuiltinTemplate.library,\n      hint: 'Create vite libraries',\n    },\n  ];\n}\n"
  },
  {
    "path": "packages/cli/src/create/prompts.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport validateNpmPackageName from 'validate-npm-package-name';\n\nimport { accent } from '../utils/terminal.js';\nimport { getRandomProjectName } from './random-name.js';\nimport { getProjectDirFromPackageName } from './utils.js';\n\nexport async function promptPackageNameAndTargetDir(\n  defaultPackageName: string,\n  interactive?: boolean,\n) {\n  let packageName: string;\n  let targetDir: string;\n\n  if (interactive) {\n    const selected = await prompts.text({\n      message: 'Package name:',\n      placeholder: defaultPackageName,\n      defaultValue: defaultPackageName,\n      validate: (value) => {\n        if (value == null || value.length === 0) {\n          return;\n        }\n        const result = value ? validateNpmPackageName(value) : null;\n        if (result?.validForNewPackages) {\n          return;\n        }\n        return result?.errors?.[0] ?? result?.warnings?.[0] ?? 'Invalid package name';\n      },\n    });\n    if (prompts.isCancel(selected)) {\n      cancelAndExit();\n    }\n    packageName = selected;\n    targetDir = getProjectDirFromPackageName(packageName);\n  } else {\n    // --no-interactive: use default\n    packageName = defaultPackageName;\n    targetDir = getProjectDirFromPackageName(packageName);\n    prompts.log.info(`Using default package name: ${accent(packageName)}`);\n  }\n\n  return { packageName, targetDir };\n}\n\nexport async function promptTargetDir(\n  defaultTargetDir: string,\n  interactive?: boolean,\n  options?: { cwd?: string },\n) {\n  let targetDir: string;\n\n  if (interactive) {\n    const selected = await prompts.text({\n      message: 'Target directory:',\n      placeholder: defaultTargetDir,\n      defaultValue: defaultTargetDir,\n      validate: (value) => validateTargetDir(value ?? defaultTargetDir, options?.cwd).error,\n    });\n    if (prompts.isCancel(selected)) {\n      cancelAndExit();\n    }\n    targetDir = validateTargetDir(selected ?? defaultTargetDir, options?.cwd).directory;\n  } else {\n    targetDir = validateTargetDir(defaultTargetDir, options?.cwd).directory;\n    prompts.log.info(`Using default target directory: ${accent(targetDir)}`);\n  }\n\n  return targetDir;\n}\n\nexport function suggestAvailableTargetDir(defaultTargetDir: string, cwd: string) {\n  let suggestedTargetDir = defaultTargetDir;\n  let attempt = 1;\n\n  while (!isTargetDirAvailable(path.join(cwd, suggestedTargetDir))) {\n    suggestedTargetDir = getRandomProjectName({ fallbackName: `${defaultTargetDir}-${attempt}` });\n    attempt++;\n  }\n\n  return suggestedTargetDir;\n}\n\nexport async function checkProjectDirExists(projectDirFullPath: string, interactive?: boolean) {\n  if (isTargetDirAvailable(projectDirFullPath)) {\n    return;\n  }\n  if (!interactive) {\n    prompts.log.info(\n      'Use --directory to specify a different location or remove the directory first',\n    );\n    cancelAndExit(`Target directory \"${projectDirFullPath}\" is not empty`, 1);\n  }\n\n  // Handle directory if it exists and is not empty\n  const overwrite = await prompts.select({\n    message: `Target directory \"${projectDirFullPath}\" is not empty. Please choose how to proceed:`,\n    options: [\n      {\n        label: 'Cancel operation',\n        value: 'no',\n      },\n      {\n        label: 'Remove existing files and continue',\n        value: 'yes',\n      },\n    ],\n  });\n\n  if (prompts.isCancel(overwrite)) {\n    cancelAndExit();\n  }\n\n  switch (overwrite) {\n    case 'yes':\n      emptyDir(projectDirFullPath);\n      break;\n    case 'no':\n      cancelAndExit();\n  }\n}\n\nexport function cancelAndExit(message = 'Operation cancelled', exitCode = 0): never {\n  prompts.cancel(message);\n  process.exit(exitCode);\n}\n\nfunction isEmpty(path: string) {\n  const files = fs.readdirSync(path);\n  return files.length === 0 || (files.length === 1 && files[0] === '.git');\n}\n\nfunction emptyDir(dir: string) {\n  if (!fs.existsSync(dir)) {\n    return;\n  }\n  for (const file of fs.readdirSync(dir)) {\n    if (file === '.git') {\n      continue;\n    }\n    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true });\n  }\n}\n\nexport function isTargetDirAvailable(projectDirFullPath: string) {\n  return !fs.existsSync(projectDirFullPath) || isEmpty(projectDirFullPath);\n}\n\nfunction validateTargetDir(input?: string, cwd?: string): { directory: string; error?: string } {\n  const value = input?.trim() ?? '';\n  if (!value) {\n    return { directory: '', error: 'Target directory is required' };\n  }\n\n  const targetDir = path.normalize(value);\n  if (!targetDir || targetDir === '.') {\n    return { directory: '', error: 'Target directory is required' };\n  }\n  if (path.isAbsolute(targetDir)) {\n    return { directory: '', error: 'Absolute path is not allowed' };\n  }\n  if (targetDir.includes('..')) {\n    return { directory: '', error: 'Relative path contains \"..\" which is not allowed' };\n  }\n  if (cwd && !isTargetDirAvailable(path.join(cwd, targetDir))) {\n    return { directory: '', error: `Target directory \"${targetDir}\" already exists` };\n  }\n  return { directory: targetDir };\n}\n"
  },
  {
    "path": "packages/cli/src/create/random-name.ts",
    "content": "import { getRandomWord } from '@nkzw/safe-word-list';\n\nconst isTest = process.env.VITE_PLUS_CLI_TEST === '1';\n\nexport default function getRandomWords(): ReadonlyArray<string> {\n  const first = getRandomWord();\n  let second: string;\n  do {\n    second = getRandomWord();\n  } while (second === first);\n  return [first, second];\n}\n\nexport function getRandomProjectName(\n  options: {\n    scope?: string;\n    fallbackName?: string;\n  } = {},\n): string {\n  const { scope, fallbackName } = options;\n  const projectName = isTest && fallbackName ? fallbackName : getRandomWords().join('-');\n  return scope ? `${scope}/${projectName}` : projectName;\n}\n"
  },
  {
    "path": "packages/cli/src/create/templates/builtin.ts",
    "content": "import assert from 'node:assert';\nimport path from 'node:path';\n\nimport type { WorkspaceInfo } from '../../types/index.js';\nimport type { ExecutionResult } from '../command.js';\nimport { discoverTemplate } from '../discovery.js';\nimport { setPackageName } from '../utils.js';\nimport { executeGeneratorScaffold } from './generator.js';\nimport { runRemoteTemplateCommand } from './remote.js';\nimport { BuiltinTemplate, type BuiltinTemplateInfo, LibraryTemplateRepo } from './types.js';\n\nexport async function executeBuiltinTemplate(\n  workspaceInfo: WorkspaceInfo,\n  templateInfo: BuiltinTemplateInfo,\n  options?: { silent?: boolean },\n): Promise<ExecutionResult> {\n  assert(templateInfo.targetDir, 'targetDir is required');\n  assert(templateInfo.packageName, 'packageName is required');\n\n  if (templateInfo.command === BuiltinTemplate.generator) {\n    return await executeGeneratorScaffold(workspaceInfo, templateInfo, options);\n  }\n\n  if (templateInfo.command === BuiltinTemplate.application) {\n    templateInfo.command = 'create-vite@latest';\n    if (!templateInfo.interactive) {\n      templateInfo.args.push('--no-interactive');\n    }\n    templateInfo.args.unshift(templateInfo.targetDir);\n  } else if (templateInfo.command === BuiltinTemplate.library) {\n    // Use degit to download the template directly from GitHub\n    const libraryTemplateInfo = discoverTemplate(\n      LibraryTemplateRepo,\n      [templateInfo.targetDir],\n      workspaceInfo,\n    );\n    const result = await runRemoteTemplateCommand(\n      workspaceInfo,\n      workspaceInfo.rootDir,\n      libraryTemplateInfo,\n      false,\n      options?.silent ?? false,\n    );\n    const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir);\n    setPackageName(fullPath, templateInfo.packageName);\n    return { ...result, projectDir: templateInfo.targetDir };\n  }\n\n  // Handle remote/external templates with fspy monitoring\n  const result = await runRemoteTemplateCommand(\n    workspaceInfo,\n    workspaceInfo.rootDir,\n    templateInfo,\n    false,\n    options?.silent ?? false,\n  );\n  const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir);\n  // set package name in the project directory\n  setPackageName(fullPath, templateInfo.packageName);\n\n  return {\n    ...result,\n    projectDir: templateInfo.targetDir,\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/create/templates/generator.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\n\nimport type { WorkspaceInfo } from '../../types/index.js';\nimport { editJsonFile } from '../../utils/json.js';\nimport { templatesDir } from '../../utils/path.js';\nimport type { ExecutionResult } from '../command.js';\nimport { copyDir } from '../utils.js';\nimport type { BuiltinTemplateInfo } from './types.js';\n\n// Execute generator scaffold template\nexport async function executeGeneratorScaffold(\n  workspaceInfo: WorkspaceInfo,\n  templateInfo: BuiltinTemplateInfo,\n  options?: { silent?: boolean },\n): Promise<ExecutionResult> {\n  if (!options?.silent) {\n    prompts.log.step('Creating generator scaffold...');\n  }\n  let description: string | undefined;\n  if (templateInfo.interactive) {\n    const defaultDescription = 'Generate new components for our monorepo';\n    const descPrompt = await prompts.text({\n      message: 'Description:',\n      placeholder: defaultDescription,\n      defaultValue: defaultDescription,\n    });\n\n    if (!prompts.isCancel(descPrompt)) {\n      description = descPrompt;\n    }\n  }\n\n  const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir);\n  // Copy template files\n  const templateDir = path.join(templatesDir, 'generator');\n  copyDir(templateDir, fullPath);\n  fs.chmodSync(path.join(fullPath, 'bin/index.ts'), '755');\n  editJsonFile(path.join(fullPath, 'package.json'), (pkg) => {\n    pkg.name = templateInfo.packageName;\n    if (description) {\n      pkg.description = description;\n    }\n    return pkg;\n  });\n\n  if (!options?.silent) {\n    prompts.log.success('Generator scaffold created');\n  }\n  return { exitCode: 0, projectDir: templateInfo.targetDir };\n}\n"
  },
  {
    "path": "packages/cli/src/create/templates/index.ts",
    "content": "export * from './builtin.js';\nexport * from './monorepo.js';\nexport * from './remote.js';\n"
  },
  {
    "path": "packages/cli/src/create/templates/monorepo.ts",
    "content": "import assert from 'node:assert';\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport spawn from 'cross-spawn';\n\nimport { rewriteMonorepoProject } from '../../migration/migrator.js';\nimport { PackageManager, type WorkspaceInfo } from '../../types/index.js';\nimport { editJsonFile } from '../../utils/json.js';\nimport { templatesDir } from '../../utils/path.js';\nimport type { ExecutionResult } from '../command.js';\nimport { discoverTemplate } from '../discovery.js';\nimport { copyDir, formatDisplayTargetDir, setPackageName } from '../utils.js';\nimport { runRemoteTemplateCommand } from './remote.js';\nimport { type BuiltinTemplateInfo, LibraryTemplateRepo } from './types.js';\n\nexport const InitialMonorepoAppDir = 'apps/website';\n\n// Execute vite:monorepo - copy from templates/monorepo\nexport async function executeMonorepoTemplate(\n  workspaceInfo: WorkspaceInfo,\n  templateInfo: BuiltinTemplateInfo,\n  interactive: boolean,\n  options?: { silent?: boolean },\n): Promise<ExecutionResult> {\n  assert(templateInfo.packageName, 'packageName is required');\n  assert(templateInfo.targetDir, 'targetDir is required');\n\n  workspaceInfo.monorepoScope = getScopeFromPackageName(templateInfo.packageName);\n  const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir);\n\n  // Ask user to init git repository before creation starts.\n  let initGit = true; // Default to yes\n  if (interactive && !options?.silent) {\n    const selected = await prompts.confirm({\n      message: `Initialize git repository:`,\n      initialValue: true,\n    });\n    if (prompts.isCancel(selected)) {\n      prompts.log.info('Operation cancelled. Skipping git initialization');\n      initGit = false;\n    } else {\n      initGit = selected;\n    }\n  } else {\n    if (!options?.silent) {\n      prompts.log.info(`Initializing git repository (default: yes)`);\n    }\n  }\n\n  if (!options?.silent) {\n    prompts.log.info(`Target directory: ${formatDisplayTargetDir(templateInfo.targetDir)}`);\n    prompts.log.step('Creating Vite+ monorepo...');\n  }\n\n  // Copy template files\n  const templateDir = path.join(templatesDir, 'monorepo');\n  copyDir(templateDir, fullPath);\n  renameFiles(fullPath);\n\n  // set project name\n  editJsonFile(path.join(fullPath, 'package.json'), (pkg) => {\n    pkg.name = templateInfo.packageName;\n    return pkg;\n  });\n\n  // Adjust package.json based on package manager\n  if (workspaceInfo.packageManager === PackageManager.pnpm) {\n    // remove workspaces field\n    editJsonFile(path.join(fullPath, 'package.json'), (pkg) => {\n      pkg.workspaces = undefined;\n      // remove resolutions field\n      pkg.resolutions = undefined;\n      return pkg;\n    });\n    const yarnrcPath = path.join(fullPath, '.yarnrc.yml');\n    if (fs.existsSync(yarnrcPath)) {\n      fs.unlinkSync(yarnrcPath);\n    }\n  } else if (workspaceInfo.packageManager === PackageManager.yarn) {\n    // remove pnpm field\n    editJsonFile(path.join(fullPath, 'package.json'), (pkg) => {\n      pkg.pnpm = undefined;\n      return pkg;\n    });\n    const pnpmWorkspacePath = path.join(fullPath, 'pnpm-workspace.yaml');\n    if (fs.existsSync(pnpmWorkspacePath)) {\n      fs.unlinkSync(pnpmWorkspacePath);\n    }\n  } else {\n    // npm\n    // remove pnpm field\n    editJsonFile(path.join(fullPath, 'package.json'), (pkg) => {\n      pkg.pnpm = undefined;\n      return pkg;\n    });\n    const pnpmWorkspacePath = path.join(fullPath, 'pnpm-workspace.yaml');\n    if (fs.existsSync(pnpmWorkspacePath)) {\n      fs.unlinkSync(pnpmWorkspacePath);\n    }\n    const yarnrcPath = path.join(fullPath, '.yarnrc.yml');\n    if (fs.existsSync(yarnrcPath)) {\n      fs.unlinkSync(yarnrcPath);\n    }\n  }\n\n  if (!options?.silent) {\n    prompts.log.success('Monorepo template created');\n  }\n\n  if (initGit) {\n    const gitResult = spawn.sync('git', ['init'], {\n      stdio: 'pipe',\n      cwd: fullPath,\n    });\n\n    if (gitResult.status === 0) {\n      if (!options?.silent) {\n        prompts.log.success('Git repository initialized');\n      }\n    } else {\n      prompts.log.warn('Failed to initialize git repository');\n      if (gitResult.stderr) {\n        prompts.log.info(gitResult.stderr.toString());\n      }\n    }\n  }\n\n  // Automatically create a default application in apps/website\n  if (!options?.silent) {\n    prompts.log.step('Creating default application in apps/website...');\n  }\n\n  const appTemplateInfo = discoverTemplate(\n    'create-vite@latest',\n    [InitialMonorepoAppDir, '--template', 'vanilla-ts', '--no-interactive'],\n    workspaceInfo,\n  );\n  const appResult = await runRemoteTemplateCommand(\n    workspaceInfo,\n    fullPath,\n    appTemplateInfo,\n    false,\n    options?.silent ?? false,\n  );\n\n  if (appResult.exitCode !== 0) {\n    prompts.log.error(`Failed to create default application: ${appResult.exitCode}`);\n    return appResult;\n  }\n\n  const appPackageName = workspaceInfo.monorepoScope\n    ? `${workspaceInfo.monorepoScope}/website`\n    : 'website';\n  const appProjectPath = path.join(fullPath, InitialMonorepoAppDir);\n  setPackageName(appProjectPath, appPackageName);\n  // Perform auto-migration on the created app\n  rewriteMonorepoProject(\n    appProjectPath,\n    workspaceInfo.packageManager,\n    undefined,\n    options?.silent ?? false,\n  );\n\n  // Automatically create a default library in packages/utils\n  if (!options?.silent) {\n    prompts.log.step('Creating default library in packages/utils...');\n  }\n  const libraryDir = 'packages/utils';\n  const libraryTemplateInfo = discoverTemplate(LibraryTemplateRepo, [libraryDir], workspaceInfo);\n  const libraryResult = await runRemoteTemplateCommand(\n    workspaceInfo,\n    fullPath,\n    libraryTemplateInfo,\n    false,\n    options?.silent ?? false,\n  );\n  if (libraryResult.exitCode !== 0) {\n    prompts.log.error(`Failed to create default library, exit code: ${libraryResult.exitCode}`);\n    return libraryResult;\n  }\n\n  const libraryPackageName = workspaceInfo.monorepoScope\n    ? `${workspaceInfo.monorepoScope}/utils`\n    : 'utils';\n  const libraryProjectPath = path.join(fullPath, libraryDir);\n  setPackageName(libraryProjectPath, libraryPackageName);\n  // Perform auto-migration on the created library\n  rewriteMonorepoProject(\n    libraryProjectPath,\n    workspaceInfo.packageManager,\n    undefined,\n    options?.silent ?? false,\n  );\n\n  return { exitCode: 0, projectDir: templateInfo.targetDir };\n}\n\nconst RENAME_FILES: Record<string, string> = {\n  _gitignore: '.gitignore',\n  _npmrc: '.npmrc',\n  '_yarnrc.yml': '.yarnrc.yml',\n};\n\nfunction renameFiles(projectDir: string) {\n  for (const [from, to] of Object.entries(RENAME_FILES)) {\n    const fromPath = path.join(projectDir, from);\n    if (fs.existsSync(fromPath)) {\n      fs.renameSync(fromPath, path.join(projectDir, to));\n    }\n  }\n}\n\nfunction getScopeFromPackageName(packageName: string) {\n  if (packageName.startsWith('@')) {\n    return packageName.split('/')[0];\n  }\n  return '';\n}\n"
  },
  {
    "path": "packages/cli/src/create/templates/remote.ts",
    "content": "import * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport colors from 'picocolors';\n\nimport type { WorkspaceInfo } from '../../types/index.js';\nimport { checkNpmPackageExists } from '../../utils/package.js';\nimport {\n  type ExecutionResult,\n  formatDlxCommand,\n  runCommand,\n  runCommandAndDetectProjectDir,\n  runCommandSilently,\n} from '../command.js';\nimport type { TemplateInfo } from './types.js';\n\nconst { gray, yellow } = colors;\n\nexport async function executeRemoteTemplate(\n  workspaceInfo: WorkspaceInfo,\n  templateInfo: TemplateInfo,\n  options?: { silent?: boolean },\n): Promise<ExecutionResult> {\n  const silent = options?.silent ?? false;\n  if (!silent) {\n    prompts.log.step('Generating project…');\n  }\n\n  let isGitHubTemplate = templateInfo.command === 'degit';\n  let result: ExecutionResult;\n  if (templateInfo.command === 'node') {\n    // Template found locally - execute directly\n    const command = templateInfo.command;\n    const args = templateInfo.args;\n    const envs = templateInfo.envs;\n    if (!silent) {\n      prompts.log.info(`Running: ${gray(`${command} ${args.join(' ')}`)}`);\n    }\n    result = await runCommandAndDetectProjectDir(\n      { command, args, cwd: workspaceInfo.rootDir, envs },\n      templateInfo.parentDir,\n    );\n  } else {\n    // TODO: prompt for project name if not provided for degit\n    // Template not found - use package manager runner (npx/pnpm dlx/etc.)\n    if (!isGitHubTemplate) {\n      // templateInfo.command is the npm package name (e.g. \"create-vite\", \"@tanstack/create-start\")\n      const packageExists = await checkNpmPackageExists(templateInfo.command);\n      if (!packageExists) {\n        if (!silent) {\n          prompts.log.error(\n            `Template \"${templateInfo.command}\" not found on npm. Run ${yellow('vp create --list')} to see available templates.`,\n          );\n        }\n        return { exitCode: 1 };\n      }\n    }\n    result = await runRemoteTemplateCommand(\n      workspaceInfo,\n      workspaceInfo.rootDir,\n      templateInfo,\n      true,\n      silent,\n    );\n  }\n\n  const exitCode = result.exitCode;\n  // Provide troubleshooting tips\n  if (exitCode === 127) {\n    prompts.log.info(yellow('\\nTroubleshooting:'));\n    prompts.log.info(`  ${gray('•')} Command not found. Make sure Node.js is installed`);\n    // prompts.log.info(`  ${gray('•')} Check if ${command} is available in PATH`);\n  } else if (isGitHubTemplate && exitCode !== 0) {\n    prompts.log.info(yellow('\\nTroubleshooting:'));\n    prompts.log.info(`  ${gray('•')} Make sure the GitHub repository exists`);\n    prompts.log.info(`  ${gray('•')} Check your internet connection`);\n    prompts.log.info(`  ${gray('•')} Repository might be private (requires authentication)`);\n  }\n  return result;\n}\n\n// Run a remote template command and support detect the created project directory\nexport async function runRemoteTemplateCommand(\n  workspaceInfo: WorkspaceInfo,\n  cwd: string,\n  templateInfo: TemplateInfo,\n  detectCreatedProjectDir?: boolean,\n  silent = false,\n): Promise<ExecutionResult> {\n  autoFixRemoteTemplateCommand(templateInfo, workspaceInfo);\n  const remotePackageName = templateInfo.command;\n  const execArgs = [...templateInfo.args];\n  const envs = templateInfo.envs;\n  const { command, args } = formatDlxCommand(remotePackageName, execArgs, workspaceInfo);\n  if (!silent) {\n    prompts.log.info(`Running: ${gray(`${command} ${args.join(' ')}`)}`);\n  }\n  if (detectCreatedProjectDir) {\n    return await runCommandAndDetectProjectDir(\n      { command, args, cwd, envs },\n      templateInfo.parentDir,\n    );\n  }\n  if (silent) {\n    return await runCommandSilently({ command, args, cwd, envs });\n  }\n  return await runCommand({ command, args, cwd, envs });\n}\n\nfunction autoFixRemoteTemplateCommand(templateInfo: TemplateInfo, workspaceInfo: WorkspaceInfo) {\n  // @tanstack/create-start@latest, create-vite@latest\n  let packageName = templateInfo.command;\n  const indexOfAt = packageName.indexOf('@', 2);\n  if (indexOfAt !== -1) {\n    packageName = packageName.substring(0, indexOfAt);\n  }\n  if (packageName === 'create-vite') {\n    // don't run dev server after installation\n    // https://github.com/vitejs/vite/blob/main/packages/create-vite/src/index.ts#L46\n    templateInfo.args.push('--no-immediate');\n    // don't present rolldown option to users\n    templateInfo.args.push('--no-rolldown');\n  } else if (packageName === '@tanstack/create-start') {\n    // don't run npm install after project creation\n    templateInfo.args.push('--no-install');\n    // don't setup toolchain automatically\n    templateInfo.args.push('--no-toolchain');\n  } else if (packageName === 'sv') {\n    // ensure create command is used\n    if (templateInfo.args[0] !== 'create') {\n      templateInfo.args.unshift('create');\n    }\n    // don't run npm install after project creation\n    templateInfo.args.push('--no-install');\n  }\n\n  if (workspaceInfo.isMonorepo) {\n    // don't run git init on monorepo\n    if (packageName === 'create-nuxt') {\n      templateInfo.args.push('--no-gitInit');\n    } else if (packageName === '@tanstack/create-start') {\n      templateInfo.args.push('--no-git');\n    }\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/create/templates/types.ts",
    "content": "export const LibraryTemplateRepo = 'github:sxzz/tsdown-templates/vite-plus';\n\nexport const BuiltinTemplate = {\n  generator: 'vite:generator',\n  monorepo: 'vite:monorepo',\n  application: 'vite:application',\n  library: 'vite:library',\n} as const;\nexport type BuiltinTemplate = (typeof BuiltinTemplate)[keyof typeof BuiltinTemplate];\n\nexport const TemplateType = {\n  builtin: 'builtin',\n  bingo: 'bingo',\n  remote: 'remote',\n} as const;\nexport type TemplateType = (typeof TemplateType)[keyof typeof TemplateType];\n\nexport interface TemplateInfo {\n  command: string;\n  args: string[];\n  envs: NodeJS.ProcessEnv;\n  type: TemplateType;\n  // The parent directory of the generated package, only for monorepo\n  // For example, \"packages\"\n  parentDir?: string;\n  interactive?: boolean;\n}\n\nexport interface BuiltinTemplateInfo extends Omit<TemplateInfo, 'parentDir'> {\n  packageName: string;\n  targetDir: string;\n}\n"
  },
  {
    "path": "packages/cli/src/create/utils.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nimport validateNpmPackageName from 'validate-npm-package-name';\n\nimport { editJsonFile } from '../utils/json.js';\nimport { getRandomProjectName } from './random-name.js';\n\n// Helper functions for file operations\nexport function copy(src: string, dest: string) {\n  const stat = fs.statSync(src);\n  if (stat.isDirectory()) {\n    copyDir(src, dest);\n  } else {\n    fs.copyFileSync(src, dest);\n  }\n}\n\nexport function copyDir(srcDir: string, destDir: string) {\n  fs.mkdirSync(destDir, { recursive: true });\n  for (const file of fs.readdirSync(srcDir)) {\n    const srcFile = path.resolve(srcDir, file);\n    const destFile = path.resolve(destDir, file);\n    copy(srcFile, destFile);\n  }\n}\n\n/**\n * Format the target directory into a valid directory name and package name\n *\n * Examples:\n * ```\n * # invalid target directories\n * /foo/bar -> { directory: '', packageName: '', error: 'Absolute path is not allowed' }\n * @scope/ -> { directory: '', packageName: '', error: 'Invalid target directory' }\n * ../../foo/bar -> { directory: '', packageName: '', error: 'Invalid target directory' }\n *\n * # valid target directories\n * . -> { directory: '.', packageName: '' }\n * ./my-package -> { directory: './my-package', packageName: 'my-package' }\n * ./foo/bar-package -> { directory: './foo/bar-package', packageName: 'bar-package' }\n * ./foo/bar-package/ -> { directory: './foo/bar-package', packageName: 'bar-package' }\n * my-package -> { directory: 'my-package', packageName: 'my-package' }\n * @my-scope/my-package -> { directory: 'my-package', packageName: '@my-scope/my-package' }\n * foo/@my-scope/my-package -> { directory: 'foo/my-package', packageName: '@scope/my-package' }\n * ./foo/@my-scope/my-package -> { directory: './foo/my-package', packageName: '@scope/my-package' }\n * ./foo/bar/@scope/my-package -> { directory: './foo/bar/my-package', packageName: '@scope/my-package' }\n * ```\n */\nexport function formatTargetDir(input: string): {\n  directory: string;\n  packageName: string;\n  error?: string;\n} {\n  let targetDir = path.normalize(input.trim());\n\n  // \".\" or \"./\" means current directory — valid directory, but no package name derivable\n  if (targetDir === '.' || targetDir === `.${path.sep}`) {\n    return { directory: '.', packageName: '' };\n  }\n\n  const parsed = path.parse(targetDir);\n  if (parsed.root || path.isAbsolute(targetDir)) {\n    return {\n      directory: '',\n      packageName: '',\n      error: 'Absolute path is not allowed',\n    };\n  }\n  if (targetDir.includes('..')) {\n    return {\n      directory: '',\n      packageName: '',\n      error: 'Relative path contains \"..\" which is not allowed',\n    };\n  }\n  let packageName = parsed.base;\n  const parentName = path.basename(parsed.dir);\n  if (parentName.startsWith('@')) {\n    // skip scope directory\n    // ./@my-scope/my-package -> ./my-package\n    targetDir = path.join(path.dirname(parsed.dir), packageName);\n    packageName = `${parentName}/${packageName}`;\n  }\n  const result = validateNpmPackageName(packageName);\n  if (!result.validForNewPackages) {\n    // invalid package name\n    const message = result.errors?.[0] ?? result.warnings?.[0] ?? 'Invalid package name';\n    return {\n      directory: '',\n      packageName: '',\n      error: `Parsed package name \"${packageName}\" is invalid: ${message}`,\n    };\n  }\n  return { directory: targetDir.split(path.sep).join('/'), packageName };\n}\n\n// Get the project directory from the project name\n// If the project name is a scoped package name, return the second part\n// Otherwise, return the project name\nexport function getProjectDirFromPackageName(packageName: string) {\n  if (packageName.startsWith('@')) {\n    return packageName.split('/')[1];\n  }\n  return packageName;\n}\n\nexport function setPackageName(projectDir: string, packageName: string) {\n  editJsonFile<{ name?: string }>(path.join(projectDir, 'package.json'), (pkg) => {\n    pkg.name = packageName;\n    return pkg;\n  });\n}\n\nexport function formatDisplayTargetDir(targetDir: string) {\n  const normalized = targetDir.split(path.sep).join('/');\n  if (normalized === '' || normalized === '.') {\n    return './';\n  }\n  if (\n    normalized.startsWith('./') ||\n    normalized.startsWith('../') ||\n    normalized.startsWith('/') ||\n    normalized.startsWith('~')\n  ) {\n    return normalized;\n  }\n  return `./${normalized}`;\n}\n\nexport function deriveDefaultPackageName(\n  cwd: string,\n  scope: string | undefined,\n  fallbackName: string,\n): string {\n  const dirName = path.basename(cwd);\n  const candidate = scope ? `${scope}/${dirName}` : dirName;\n  return validateNpmPackageName(candidate).validForNewPackages\n    ? candidate\n    : getRandomProjectName({ scope, fallbackName });\n}\n"
  },
  {
    "path": "packages/cli/src/define-config.ts",
    "content": "import {\n  defineConfig as viteDefineConfig,\n  type ConfigEnv,\n} from '@voidzero-dev/vite-plus-test/config';\n\nimport type { UserConfig } from './index';\n\ntype ViteUserConfigFnObject = (env: ConfigEnv) => UserConfig;\ntype ViteUserConfigFnPromise = (env: ConfigEnv) => Promise<UserConfig>;\ntype ViteUserConfigFn = (env: ConfigEnv) => UserConfig | Promise<UserConfig>;\ntype ViteUserConfigExport =\n  | UserConfig\n  | Promise<UserConfig>\n  | ViteUserConfigFnObject\n  | ViteUserConfigFnPromise\n  | ViteUserConfigFn;\n\nexport function defineConfig(config: UserConfig): UserConfig;\nexport function defineConfig(config: Promise<UserConfig>): Promise<UserConfig>;\nexport function defineConfig(config: ViteUserConfigFnObject): ViteUserConfigFnObject;\nexport function defineConfig(config: ViteUserConfigFnPromise): ViteUserConfigFnPromise;\nexport function defineConfig(config: ViteUserConfigExport): ViteUserConfigExport;\n\nexport function defineConfig(config: ViteUserConfigExport): ViteUserConfigExport {\n  if (typeof config === 'object') {\n    if (config instanceof Promise) {\n      return config.then((config) => {\n        if (config.lazy) {\n          return config.lazy().then(({ plugins }) =>\n            viteDefineConfig({\n              ...config,\n              plugins: [...(config.plugins || []), ...(plugins || [])],\n            }),\n          );\n        }\n        return viteDefineConfig(config);\n      });\n    } else if (config.lazy) {\n      return config.lazy().then(({ plugins }) =>\n        viteDefineConfig({\n          ...config,\n          plugins: [...(config.plugins || []), ...(plugins || [])],\n        }),\n      );\n    }\n  } else if (typeof config === 'function') {\n    return viteDefineConfig((env) => {\n      const c = config(env);\n      if (c instanceof Promise) {\n        return c.then((v) => {\n          if (v.lazy) {\n            return v\n              .lazy()\n              .then(({ plugins }) =>\n                viteDefineConfig({ ...v, plugins: [...(v.plugins || []), ...(plugins || [])] }),\n              );\n          }\n          return v;\n        });\n      }\n      if (c.lazy) {\n        return c\n          .lazy()\n          .then(({ plugins }) => ({ ...c, plugins: [...(c.plugins || []), ...(plugins || [])] }));\n      }\n      return c;\n    });\n  }\n  return viteDefineConfig(config);\n}\n"
  },
  {
    "path": "packages/cli/src/index.cts",
    "content": "const vite = require('@voidzero-dev/vite-plus-core');\n\nconst vitest = require('@voidzero-dev/vite-plus-test/config');\n\nconst { defineConfig } = require('./define-config');\n\nmodule.exports = {\n  ...vite,\n  ...vitest,\n  defineConfig,\n};\n"
  },
  {
    "path": "packages/cli/src/index.ts",
    "content": "import { type Plugin as VitestPlugin } from '@voidzero-dev/vite-plus-test/config';\nimport type { OxfmtConfig } from 'oxfmt';\nimport type { OxlintConfig } from 'oxlint';\n\nimport { defineConfig } from './define-config.js';\nimport type { PackUserConfig } from './pack';\nimport type { RunConfig } from './run-config';\nimport type { StagedConfig } from './staged-config';\n\ndeclare module '@voidzero-dev/vite-plus-core' {\n  interface UserConfig {\n    /**\n     * Options for oxlint\n     */\n    lint?: OxlintConfig;\n\n    fmt?: OxfmtConfig;\n\n    pack?: PackUserConfig | PackUserConfig[];\n\n    run?: RunConfig;\n\n    staged?: StagedConfig;\n\n    // temporary solution to load plugins lazily\n    // We need to support this in the upstream vite\n    lazy?: () => Promise<{\n      plugins?: VitestPlugin[];\n    }>;\n  }\n}\n\nexport * from '@voidzero-dev/vite-plus-core';\n\nexport * from '@voidzero-dev/vite-plus-test/config';\n\nexport { defineConfig };\n"
  },
  {
    "path": "packages/cli/src/init-config.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { mergeJsonConfig } from '../binding/index.js';\nimport { fmt as resolveFmt } from './resolve-fmt.js';\nimport { runCommandSilently } from './utils/command.js';\nimport { BASEURL_TSCONFIG_WARNING, VITE_PLUS_NAME } from './utils/constants.js';\nimport { warnMsg } from './utils/terminal.js';\nimport { hasBaseUrlInTsconfig } from './utils/tsconfig.js';\n\ninterface InitCommandSpec {\n  configKey: 'lint' | 'fmt';\n  triggerFlags: string[];\n  defaultConfigFiles: string[];\n}\n\nconst INIT_COMMAND_SPECS: Record<string, InitCommandSpec> = {\n  lint: {\n    configKey: 'lint',\n    triggerFlags: ['--init'],\n    defaultConfigFiles: ['.oxlintrc.json'],\n  },\n  fmt: {\n    configKey: 'fmt',\n    triggerFlags: ['--init', '--migrate'],\n    defaultConfigFiles: ['.oxfmtrc.json', '.oxfmtrc.jsonc'],\n  },\n};\n\nconst VITE_CONFIG_FILES = [\n  'vite.config.ts',\n  'vite.config.mts',\n  'vite.config.cts',\n  'vite.config.js',\n  'vite.config.mjs',\n  'vite.config.cjs',\n] as const;\n\nexport interface InitCommandInspection {\n  handled: boolean;\n  configKey?: 'lint' | 'fmt';\n  existingViteConfigPath?: string;\n  hasExistingConfigKey?: boolean;\n}\n\nexport interface ApplyToolInitResult {\n  handled: boolean;\n  action?: 'added' | 'skipped-existing' | 'no-generated-config';\n  configKey?: 'lint' | 'fmt';\n  viteConfigPath?: string;\n}\n\nfunction optionTerminatorIndex(args: string[]): number {\n  const index = args.indexOf('--');\n  return index === -1 ? args.length : index;\n}\n\nfunction hasTriggerFlag(args: string[], triggerFlags: string[]): boolean {\n  const limit = optionTerminatorIndex(args);\n  for (let i = 0; i < limit; i++) {\n    const arg = args[i];\n    if (triggerFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`))) {\n      return true;\n    }\n  }\n  return false;\n}\n\nfunction extractConfigPathArg(args: string[]): string | null {\n  const limit = optionTerminatorIndex(args);\n  for (let i = 0; i < limit; i++) {\n    const arg = args[i];\n    if (arg === '-c' || arg === '--config') {\n      const value = args[i + 1];\n      return value ? value : null;\n    }\n    if (arg.startsWith('--config=')) {\n      return arg.slice('--config='.length);\n    }\n    if (arg.startsWith('-c=')) {\n      return arg.slice('-c='.length);\n    }\n  }\n  return null;\n}\n\nfunction resolveGeneratedConfigPath(\n  projectPath: string,\n  args: string[],\n  defaultConfigFiles: readonly string[],\n): string | null {\n  const configArg = extractConfigPathArg(args);\n  if (configArg) {\n    const resolved = path.isAbsolute(configArg) ? configArg : path.join(projectPath, configArg);\n    if (fs.existsSync(resolved)) {\n      return resolved;\n    }\n  }\n\n  for (const filename of defaultConfigFiles) {\n    const fullPath = path.join(projectPath, filename);\n    if (fs.existsSync(fullPath)) {\n      return fullPath;\n    }\n  }\n\n  return null;\n}\n\nfunction findViteConfigPath(projectPath: string): string | null {\n  for (const filename of VITE_CONFIG_FILES) {\n    const fullPath = path.join(projectPath, filename);\n    if (fs.existsSync(fullPath)) {\n      return fullPath;\n    }\n  }\n  return null;\n}\n\nfunction ensureViteConfigPath(projectPath: string): string {\n  const existing = findViteConfigPath(projectPath);\n  if (existing) {\n    return existing;\n  }\n  const viteConfigPath = path.join(projectPath, 'vite.config.ts');\n  fs.writeFileSync(\n    viteConfigPath,\n    `import { defineConfig } from '${VITE_PLUS_NAME}';\n\nexport default defineConfig({});\n`,\n  );\n  return viteConfigPath;\n}\n\nfunction hasConfigKey(viteConfigPath: string, configKey: string): boolean {\n  const viteConfig = fs.readFileSync(viteConfigPath, 'utf8');\n  return new RegExp(`\\\\b${configKey}\\\\s*:`).test(viteConfig);\n}\n\nasync function vpFmt(cwd: string, filePath: string): Promise<void> {\n  const { binPath, envs } = await resolveFmt();\n  const result = await runCommandSilently({\n    command: binPath,\n    args: ['--write', filePath],\n    cwd,\n    envs: {\n      ...process.env,\n      ...envs,\n    },\n  });\n  if (result.exitCode !== 0) {\n    warnMsg(\n      `Failed to format ${filePath} with vp fmt:\\n${result.stdout.toString()}${result.stderr.toString()}`,\n    );\n  }\n}\n\nfunction resolveInitSpec(command: string | undefined, args: string[]): InitCommandSpec | null {\n  if (!command) {\n    return null;\n  }\n  const spec = INIT_COMMAND_SPECS[command];\n  if (!spec || !hasTriggerFlag(args, spec.triggerFlags)) {\n    return null;\n  }\n  return spec;\n}\n\nexport function inspectInitCommand(\n  command: string | undefined,\n  args: string[],\n  projectPath = process.cwd(),\n): InitCommandInspection {\n  const spec = resolveInitSpec(command, args);\n  if (!spec) {\n    return { handled: false };\n  }\n\n  const viteConfigPath = findViteConfigPath(projectPath);\n  if (!viteConfigPath) {\n    return {\n      handled: true,\n      configKey: spec.configKey,\n      hasExistingConfigKey: false,\n    };\n  }\n\n  return {\n    handled: true,\n    configKey: spec.configKey,\n    existingViteConfigPath: viteConfigPath,\n    hasExistingConfigKey: hasConfigKey(viteConfigPath, spec.configKey),\n  };\n}\n\n/**\n * Merge generated tool config from `vp lint/fmt --init` (and fmt --migrate)\n * into the project's vite config, then remove the generated standalone file.\n *\n * Returns true when the command was an init/migrate command (handled), false otherwise.\n */\nexport async function applyToolInitConfigToViteConfig(\n  command: string | undefined,\n  args: string[],\n  projectPath = process.cwd(),\n): Promise<ApplyToolInitResult> {\n  const inspection = inspectInitCommand(command, args, projectPath);\n  if (!inspection.handled || !inspection.configKey) {\n    return { handled: false };\n  }\n  const spec = INIT_COMMAND_SPECS[command as keyof typeof INIT_COMMAND_SPECS];\n  const viteConfigPath = ensureViteConfigPath(projectPath);\n  const generatedConfigPath = resolveGeneratedConfigPath(\n    projectPath,\n    args,\n    spec.defaultConfigFiles,\n  );\n\n  if (hasConfigKey(viteConfigPath, spec.configKey)) {\n    if (generatedConfigPath) {\n      fs.rmSync(generatedConfigPath, { force: true });\n    }\n    return {\n      handled: true,\n      action: 'skipped-existing',\n      configKey: spec.configKey,\n      viteConfigPath,\n    };\n  }\n\n  if (spec.configKey === 'lint' && hasTriggerFlag(args, ['--init'])) {\n    const lintInitConfigPath = path.join(projectPath, '.vite-plus-lint-init.oxlintrc.json');\n    // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint)\n    const hasBaseUrl = hasBaseUrlInTsconfig(projectPath);\n    const initOptions = hasBaseUrl ? {} : { typeAware: true, typeCheck: true };\n    if (hasBaseUrl) {\n      warnMsg(BASEURL_TSCONFIG_WARNING);\n    }\n    fs.writeFileSync(lintInitConfigPath, JSON.stringify({ options: initOptions }));\n    const mergeResult = mergeJsonConfig(viteConfigPath, lintInitConfigPath, spec.configKey);\n\n    if (!mergeResult.updated) {\n      throw new Error(`Failed to initialize lint config in ${path.basename(viteConfigPath)}`);\n    }\n\n    fs.writeFileSync(viteConfigPath, mergeResult.content);\n    fs.rmSync(lintInitConfigPath, { force: true });\n    if (generatedConfigPath) {\n      fs.rmSync(generatedConfigPath, { force: true });\n    }\n    await vpFmt(projectPath, path.relative(projectPath, viteConfigPath));\n    return {\n      handled: true,\n      action: 'added',\n      configKey: spec.configKey,\n      viteConfigPath,\n    };\n  }\n\n  if (!generatedConfigPath) {\n    return {\n      handled: true,\n      action: 'no-generated-config',\n      configKey: inspection.configKey,\n      viteConfigPath,\n    };\n  }\n\n  const mergeResult = mergeJsonConfig(viteConfigPath, generatedConfigPath, spec.configKey);\n  if (!mergeResult.updated) {\n    throw new Error(\n      `Failed to merge ${path.basename(generatedConfigPath)} into ${path.basename(viteConfigPath)}`,\n    );\n  }\n\n  fs.writeFileSync(viteConfigPath, mergeResult.content);\n  fs.rmSync(generatedConfigPath, { force: true });\n  await vpFmt(projectPath, path.relative(projectPath, viteConfigPath));\n  return {\n    handled: true,\n    action: 'added',\n    configKey: spec.configKey,\n    viteConfigPath,\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/lint.ts",
    "content": "export {\n  type AllowWarnDeny,\n  type DummyRule,\n  type DummyRuleMap,\n  type ExternalPluginEntry,\n  type ExternalPluginsConfig,\n  type OxlintConfig,\n  type OxlintEnv,\n  type OxlintGlobals,\n  type OxlintOverride,\n  type RuleCategories,\n  defineConfig,\n} from 'oxlint';\n"
  },
  {
    "path": "packages/cli/src/mcp/bin.ts",
    "content": "import { existsSync, readdirSync, readFileSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\ninterface DocPage {\n  slug: string;\n  relativePath: string;\n  title: string;\n  content: string;\n}\n\ninterface SearchResult {\n  slug: string;\n  title: string;\n  snippet: string;\n  score: number;\n}\n\ninterface JsonRpcRequest {\n  jsonrpc: string;\n  id?: number | string | null;\n  method: string;\n  params?: Record<string, unknown>;\n}\n\ninterface DocIndex {\n  pages: DocPage[];\n  byAlias: Map<string, DocPage>;\n}\n\nconst PROTOCOL_VERSION = '2024-11-05';\n\nconst TOOLS = [\n  {\n    name: 'list_pages',\n    description: 'List all Vite+ documentation pages with their slugs and titles',\n    inputSchema: { type: 'object' as const, properties: {} },\n  },\n  {\n    name: 'get_page',\n    description: 'Get the full content of a Vite+ documentation page by slug',\n    inputSchema: {\n      type: 'object' as const,\n      properties: {\n        slug: {\n          type: 'string',\n          description: \"Page slug, e.g. 'vite/guide/cli' or 'cli'\",\n        },\n      },\n      required: ['slug'],\n    },\n  },\n  {\n    name: 'search_docs',\n    description:\n      'Search Vite+ documentation by keyword query. Returns top 5 matching pages with snippets.',\n    inputSchema: {\n      type: 'object' as const,\n      properties: {\n        query: {\n          type: 'string',\n          description: \"Search query, e.g. 'dev server' or 'testing'\",\n        },\n      },\n      required: ['query'],\n    },\n  },\n];\n\nfunction findPackageRoot(from: string): string {\n  let dir = from;\n  while (true) {\n    if (existsSync(join(dir, 'package.json'))) {\n      return dir;\n    }\n    const parent = dirname(dir);\n    if (parent === dir) {\n      break;\n    }\n    dir = parent;\n  }\n  throw new Error('Could not find package.json from: ' + from);\n}\n\nfunction readPackageVersion(pkgRoot: string): string {\n  const raw = readFileSync(join(pkgRoot, 'package.json'), 'utf8');\n  const pkg = JSON.parse(raw) as { version?: string };\n  return pkg.version ?? '0.0.0';\n}\n\nfunction resolveDocsDir(pkgRoot: string): string {\n  const bundledDocsDir = join(pkgRoot, 'skills', 'vite-plus', 'docs');\n  if (existsSync(bundledDocsDir)) {\n    return bundledDocsDir;\n  }\n\n  const workspaceDocsDir = join(pkgRoot, '..', '..', 'docs');\n  if (existsSync(workspaceDocsDir)) {\n    return workspaceDocsDir;\n  }\n\n  throw new Error(`Vite+ docs directory not found. Expected bundled docs at: ${bundledDocsDir}`);\n}\n\nfunction collectMarkdownFiles(rootDir: string, relativeDir = ''): string[] {\n  const currentDir = join(rootDir, relativeDir);\n  const entries = readdirSync(currentDir, { withFileTypes: true });\n  const files: string[] = [];\n\n  for (const entry of entries) {\n    const relPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;\n    if (entry.isDirectory()) {\n      if (entry.name === 'node_modules') {\n        continue;\n      }\n      files.push(...collectMarkdownFiles(rootDir, relPath));\n      continue;\n    }\n    if (entry.isFile() && entry.name.endsWith('.md')) {\n      files.push(relPath);\n    }\n  }\n\n  // eslint-disable-next-line unicorn/no-array-sort -- deterministic ordering for stable alias resolution\n  files.sort();\n  return files;\n}\n\nfunction normalizeDocId(value: string): string {\n  const normalized = value\n    .trim()\n    .replace(/\\\\/g, '/')\n    .replace(/^docs\\//, '')\n    .replace(/\\.md$/, '')\n    .replace(/\\/index$/, '')\n    .replace(/^\\/+|\\/+$/g, '');\n  return normalized || 'index';\n}\n\nfunction createSlug(relativePath: string): string {\n  const withoutExt = relativePath.replace(/\\.md$/, '');\n  if (withoutExt === 'index') {\n    return 'index';\n  }\n  if (withoutExt.endsWith('/index')) {\n    return withoutExt.slice(0, -'/index'.length);\n  }\n  return withoutExt;\n}\n\nfunction buildAliases(page: DocPage, basenameCounts: Map<string, number>): string[] {\n  const aliases = new Set<string>();\n  const relativeNoExt = page.relativePath.replace(/\\.md$/, '');\n  const flatSlug = page.slug.replaceAll('/', '-');\n  const baseName = page.slug.split('/').at(-1);\n\n  aliases.add(page.slug);\n  aliases.add(`${page.slug}.md`);\n  aliases.add(relativeNoExt);\n  aliases.add(page.relativePath);\n  aliases.add(`docs/${relativeNoExt}`);\n  aliases.add(`docs/${page.relativePath}`);\n  aliases.add(flatSlug);\n  aliases.add(`${flatSlug}.md`);\n\n  if (baseName && (basenameCounts.get(baseName) ?? 0) === 1) {\n    aliases.add(baseName);\n    aliases.add(`${baseName}.md`);\n  }\n\n  return [...aliases];\n}\n\nfunction loadDocs(pkgRoot: string): DocIndex {\n  const docsDir = resolveDocsDir(pkgRoot);\n  const files = collectMarkdownFiles(docsDir);\n  const pages: DocPage[] = files.map((relativePath) => {\n    const raw = readFileSync(join(docsDir, relativePath), 'utf8');\n    const content = raw.replace(/^---\\n[\\s\\S]*?\\n---\\n/, '');\n    const titleMatch = content.match(/^#\\s+(.+)/m);\n    const slug = createSlug(relativePath);\n    const title = titleMatch ? titleMatch[1].trim() : slug;\n    return { slug, relativePath, title, content };\n  });\n\n  const basenameCounts = new Map<string, number>();\n  for (const page of pages) {\n    const baseName = page.slug.split('/').at(-1);\n    if (!baseName) {\n      continue;\n    }\n    basenameCounts.set(baseName, (basenameCounts.get(baseName) ?? 0) + 1);\n  }\n\n  const aliasCounts = new Map<string, number>();\n  const aliasSources = new Map<DocPage, string[]>();\n  for (const page of pages) {\n    const aliases = [...new Set(buildAliases(page, basenameCounts).map(normalizeDocId))];\n    aliasSources.set(page, aliases);\n    for (const alias of aliases) {\n      aliasCounts.set(alias, (aliasCounts.get(alias) ?? 0) + 1);\n    }\n  }\n\n  const byAlias = new Map<string, DocPage>();\n  for (const page of pages) {\n    for (const alias of aliasSources.get(page) ?? []) {\n      if ((aliasCounts.get(alias) ?? 0) !== 1) {\n        continue;\n      }\n      byAlias.set(alias, page);\n    }\n  }\n\n  return { pages, byAlias };\n}\n\nfunction searchDocs(pages: DocPage[], query: string): SearchResult[] {\n  const terms = query\n    .toLowerCase()\n    .split(/\\s+/)\n    .filter((term) => term.length > 0);\n  if (terms.length === 0) {\n    return [];\n  }\n\n  const scored: SearchResult[] = [];\n  for (const page of pages) {\n    const titleLower = page.title.toLowerCase();\n    const contentLower = page.content.toLowerCase();\n    let score = 0;\n    let firstMatchIndex = -1;\n\n    for (const term of terms) {\n      let idx = 0;\n      while ((idx = titleLower.indexOf(term, idx)) !== -1) {\n        score += 3;\n        idx += term.length;\n      }\n\n      idx = 0;\n      while ((idx = contentLower.indexOf(term, idx)) !== -1) {\n        score += 1;\n        if (firstMatchIndex === -1) {\n          firstMatchIndex = idx;\n        }\n        idx += term.length;\n      }\n    }\n\n    if (score === 0) {\n      continue;\n    }\n\n    let snippet: string;\n    if (firstMatchIndex !== -1) {\n      const start = Math.max(0, firstMatchIndex - 80);\n      const end = Math.min(page.content.length, firstMatchIndex + 120);\n      snippet =\n        (start > 0 ? '...' : '') +\n        page.content.slice(start, end).trim() +\n        (end < page.content.length ? '...' : '');\n    } else {\n      snippet = page.content.slice(0, 200).trim() + '...';\n    }\n\n    scored.push({ slug: page.slug, title: page.title, snippet, score });\n  }\n\n  scored.sort((a, b) => b.score - a.score);\n  return scored.slice(0, 5);\n}\n\nfunction resolvePageBySlug(index: DocIndex, rawSlug: string): DocPage | undefined {\n  return index.byAlias.get(normalizeDocId(rawSlug));\n}\n\nfunction makeErrorResponse(id: number | string | null, code: number, message: string): object {\n  return {\n    jsonrpc: '2.0',\n    id,\n    error: { code, message },\n  };\n}\n\nfunction handleRequest(index: DocIndex, serverVersion: string, req: JsonRpcRequest): object | null {\n  const { method, id, params } = req;\n\n  if (method === 'initialize') {\n    return {\n      jsonrpc: '2.0',\n      id: id ?? null,\n      result: {\n        protocolVersion: PROTOCOL_VERSION,\n        capabilities: { tools: {} },\n        serverInfo: { name: 'vite-plus', version: serverVersion },\n      },\n    };\n  }\n\n  if (method === 'notifications/initialized' || method === '$/cancelRequest') {\n    return null;\n  }\n\n  if (method === 'ping') {\n    return { jsonrpc: '2.0', id: id ?? null, result: {} };\n  }\n\n  if (method === 'tools/list') {\n    return { jsonrpc: '2.0', id: id ?? null, result: { tools: TOOLS } };\n  }\n\n  if (method === 'tools/call') {\n    const toolName = (params?.name as string | undefined) ?? '';\n    const toolArgs = (params?.arguments as Record<string, unknown> | undefined) ?? {};\n\n    if (toolName === 'list_pages') {\n      const list = index.pages.map((page) => ({\n        slug: page.slug,\n        title: page.title,\n        path: `docs/${page.relativePath}`,\n      }));\n      return {\n        jsonrpc: '2.0',\n        id: id ?? null,\n        result: { content: [{ type: 'text', text: JSON.stringify(list, null, 2) }] },\n      };\n    }\n\n    if (toolName === 'get_page') {\n      const slug = toolArgs.slug;\n      if (typeof slug !== 'string' || slug.trim().length === 0) {\n        return {\n          jsonrpc: '2.0',\n          id: id ?? null,\n          result: {\n            content: [{ type: 'text', text: 'Missing required string argument: slug' }],\n            isError: true,\n          },\n        };\n      }\n      const page = resolvePageBySlug(index, slug);\n      if (!page) {\n        return {\n          jsonrpc: '2.0',\n          id: id ?? null,\n          result: {\n            content: [{ type: 'text', text: `Page not found: ${slug}` }],\n            isError: true,\n          },\n        };\n      }\n      return {\n        jsonrpc: '2.0',\n        id: id ?? null,\n        result: { content: [{ type: 'text', text: page.content }] },\n      };\n    }\n\n    if (toolName === 'search_docs') {\n      const query = toolArgs.query;\n      if (typeof query !== 'string' || query.trim().length === 0) {\n        return {\n          jsonrpc: '2.0',\n          id: id ?? null,\n          result: {\n            content: [{ type: 'text', text: 'Missing required string argument: query' }],\n            isError: true,\n          },\n        };\n      }\n      const results = searchDocs(index.pages, query);\n      return {\n        jsonrpc: '2.0',\n        id: id ?? null,\n        result: { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] },\n      };\n    }\n\n    if (id === undefined) {\n      return null;\n    }\n    return makeErrorResponse(id, -32601, `Unknown tool: ${toolName}`);\n  }\n\n  if (id === undefined) {\n    return null;\n  }\n  return makeErrorResponse(id, -32601, `Unknown method: ${method}`);\n}\n\nfunction writeMessage(payload: object): void {\n  const body = JSON.stringify(payload);\n  const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\\r\\n\\r\\n`;\n  process.stdout.write(header + body);\n}\n\nfunction findHeadersBoundary(buffer: Buffer): { end: number; separatorLength: 2 | 4 } | null {\n  const crlf = buffer.indexOf('\\r\\n\\r\\n');\n  const lf = buffer.indexOf('\\n\\n');\n\n  if (crlf === -1 && lf === -1) {\n    return null;\n  }\n  if (crlf !== -1 && (lf === -1 || crlf < lf)) {\n    return { end: crlf, separatorLength: 4 };\n  }\n  return { end: lf, separatorLength: 2 };\n}\n\nfunction parseContentLength(rawHeaders: string): number | null {\n  const lines = rawHeaders.split(/\\r?\\n/);\n  for (const line of lines) {\n    const match = line.match(/^content-length:\\s*(\\d+)\\s*$/i);\n    if (match) {\n      const value = Number.parseInt(match[1], 10);\n      if (Number.isSafeInteger(value) && value >= 0) {\n        return value;\n      }\n      return null;\n    }\n  }\n  return null;\n}\n\nfunction startStdioServer(index: DocIndex, serverVersion: string): void {\n  let buffer = Buffer.alloc(0);\n\n  process.stdin.on('error', () => {\n    process.exit(1);\n  });\n\n  process.stdin.on('end', () => {\n    process.exit(0);\n  });\n\n  process.stdin.on('data', (chunk: Buffer) => {\n    buffer = Buffer.concat([buffer, chunk]);\n\n    while (true) {\n      const boundary = findHeadersBoundary(buffer);\n      if (!boundary) {\n        break;\n      }\n\n      const headerText = buffer.subarray(0, boundary.end).toString('utf8');\n      const contentLength = parseContentLength(headerText);\n      const bodyStart = boundary.end + boundary.separatorLength;\n\n      if (contentLength === null) {\n        writeMessage(makeErrorResponse(null, -32600, 'Missing or invalid Content-Length header'));\n        buffer = buffer.subarray(bodyStart);\n        continue;\n      }\n\n      const bodyEnd = bodyStart + contentLength;\n      if (buffer.length < bodyEnd) {\n        break;\n      }\n\n      const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');\n      buffer = buffer.subarray(bodyEnd);\n\n      let request: JsonRpcRequest;\n      try {\n        request = JSON.parse(body) as JsonRpcRequest;\n      } catch {\n        writeMessage(makeErrorResponse(null, -32700, 'Parse error'));\n        continue;\n      }\n\n      if (typeof request.method !== 'string') {\n        writeMessage(\n          makeErrorResponse(request.id ?? null, -32600, 'Invalid request: missing \"method\" field'),\n        );\n        continue;\n      }\n\n      const response = handleRequest(index, serverVersion, request);\n      if (response !== null) {\n        writeMessage(response);\n      }\n    }\n  });\n}\n\ntry {\n  const packageRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url)));\n  const serverVersion = readPackageVersion(packageRoot);\n  const docs = loadDocs(packageRoot);\n  startStdioServer(docs, serverVersion);\n} catch (err) {\n  process.stderr.write(\n    `[vite-plus mcp] Failed to start: ${err instanceof Error ? err.message : String(err)}\\n`,\n  );\n  process.exit(1);\n}\n"
  },
  {
    "path": "packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`rewritePackageJson > should rewrite devDependencies and dependencies on npm monorepo project 1`] = `\n{\n  \"dependencies\": {\n    \"foo\": \"1.0.0\",\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\",\n  },\n}\n`;\n\nexports[`rewritePackageJson > should rewrite devDependencies and dependencies on pnpm monorepo project 1`] = `\n{\n  \"dependencies\": {\n    \"foo\": \"1.0.0\",\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"catalog:\",\n  },\n}\n`;\n\nexports[`rewritePackageJson > should rewrite devDependencies and dependencies on standalone project 1`] = `\n{\n  \"dependencies\": {\n    \"foo\": \"1.0.0\",\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"latest\",\n  },\n}\n`;\n\nexports[`rewritePackageJson > should rewrite devDependencies and dependencies on yarn monorepo project 1`] = `\n{\n  \"dependencies\": {\n    \"foo\": \"1.0.0\",\n  },\n  \"devDependencies\": {\n    \"vite-plus\": \"catalog:\",\n  },\n}\n`;\n\nexports[`rewritePackageJson > should rewrite package.json scripts and extract staged config 1`] = `\n{\n  \"lint-staged\": {\n    \"*.js\": [\n      \"oxlint --fix --type-aware\",\n      \"oxfmt --fix\",\n    ],\n    \"*.ts\": \"oxfmt --fix\",\n  },\n  \"scripts\": {\n    \"build\": \"pnpm install && vp build -r && vp run build --watch && vp pack && tsc || exit 1\",\n    \"dev\": \"vp dev\",\n    \"dev_analyze\": \"vp dev --analyze\",\n    \"dev_cjs\": \"VITE_CJS_IGNORE_WARNING=true vp dev\",\n    \"dev_cjs_cross_env\": \"cross-env VITE_CJS_IGNORE_WARNING=true vp dev\",\n    \"dev_debug\": \"vp dev --debug\",\n    \"dev_help\": \"vp dev --help && vp dev -h\",\n    \"dev_host\": \"vp dev --host 0.0.0.0\",\n    \"dev_open\": \"vp dev --open\",\n    \"dev_port\": \"vp dev --port 3000\",\n    \"dev_profile\": \"vp dev --profile\",\n    \"dev_stats\": \"vp dev --stats\",\n    \"dev_trace\": \"vp dev --trace\",\n    \"dev_verbose\": \"vp dev --verbose\",\n    \"fmt\": \"vp fmt\",\n    \"fmt_config\": \"vp fmt --config .oxfmt.json\",\n    \"lint\": \"vp lint\",\n    \"lint_config\": \"vp lint --config .oxlint.json\",\n    \"lint_type_aware\": \"vp lint --type-aware\",\n    \"optimize\": \"vp optimize\",\n    \"pack\": \"vp pack\",\n    \"pack_watch\": \"vp pack --watch\",\n    \"preview\": \"vp preview\",\n    \"ready\": \"vp lint --fix --type-aware && vp test run && vp pack && vp fmt --fix\",\n    \"ready_env\": \"NODE_ENV=test FOO=bar vp lint --fix --type-aware && NODE_ENV=test FOO=bar vp test run && NODE_ENV=test FOO=bar vp pack && NODE_ENV=test FOO=bar vp fmt --fix\",\n    \"ready_new\": \"vp install && vp fmt && vp lint --type-aware && vp test -r && vp build -r\",\n    \"test\": \"vp test\",\n    \"test_run\": \"vp test run && vp test --ui\",\n    \"version\": \"vp --version\",\n    \"version_short\": \"vp -v\",\n  },\n}\n`;\n\nexports[`rewritePackageJson > should rewrite package.json scripts and extract staged config 2`] = `\n{\n  \"*.js\": [\n    \"vp lint --fix --type-aware\",\n    \"vp fmt --fix\",\n  ],\n  \"*.ts\": \"vp fmt --fix\",\n}\n`;\n"
  },
  {
    "path": "packages/cli/src/migration/__tests__/compat.spec.ts",
    "content": "import { describe, expect, it } from 'vitest';\n\nimport { checkManualChunksCompat } from '../compat.js';\nimport { createMigrationReport } from '../report.js';\n\ndescribe('checkManualChunksCompat', () => {\n  it('should warn when manualChunks is an object', () => {\n    const report = createMigrationReport();\n    checkManualChunksCompat({ manualChunks: { react: ['react', 'react-dom'] } }, report);\n    expect(report.warnings).toHaveLength(1);\n    expect(report.warnings[0]).toContain('Object-form');\n    expect(report.warnings[0]).toContain('codeSplitting');\n  });\n\n  it('should not warn when manualChunks is a function', () => {\n    const report = createMigrationReport();\n    checkManualChunksCompat({ manualChunks: () => undefined }, report);\n    expect(report.warnings).toHaveLength(0);\n  });\n\n  it('should not warn when manualChunks is not set', () => {\n    const report = createMigrationReport();\n    checkManualChunksCompat({}, report);\n    expect(report.warnings).toHaveLength(0);\n  });\n\n  it('should not warn when output is undefined', () => {\n    const report = createMigrationReport();\n    checkManualChunksCompat(undefined, report);\n    expect(report.warnings).toHaveLength(0);\n  });\n\n  it('should handle array of outputs', () => {\n    const report = createMigrationReport();\n    checkManualChunksCompat(\n      [{ manualChunks: () => undefined }, { manualChunks: { vendor: ['lodash'] } }],\n      report,\n    );\n    expect(report.warnings).toHaveLength(1);\n  });\n\n  it('should only add one warning for multiple object-form outputs', () => {\n    const report = createMigrationReport();\n    checkManualChunksCompat(\n      [{ manualChunks: { react: ['react'] } }, { manualChunks: { lodash: ['lodash'] } }],\n      report,\n    );\n    expect(report.warnings).toHaveLength(1);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/migration/__tests__/migrator.spec.ts",
    "content": "import { describe, expect, it, vi } from 'vitest';\n\nimport { PackageManager } from '../../types/index.js';\n\n// Mock VITE_PLUS_VERSION to a stable value for snapshot tests.\n// When tests run via `vp test`, the env var is injected with the actual version,\n// which would cause snapshot mismatches.\nvi.mock('../../utils/constants.js', async (importOriginal) => {\n  const mod = await importOriginal<typeof import('../../utils/constants.js')>();\n  return { ...mod, VITE_PLUS_VERSION: 'latest' };\n});\n\nconst { rewritePackageJson } = await import('../migrator.js');\n\ndescribe('rewritePackageJson', () => {\n  it('should rewrite package.json scripts and extract staged config', async () => {\n    const pkg = {\n      scripts: {\n        test: 'vitest',\n        test_run: 'vitest run && vitest --ui',\n        lint: 'oxlint',\n        lint_config: 'oxlint --config .oxlint.json',\n        lint_type_aware: 'oxlint --type-aware',\n        fmt: 'oxfmt',\n        fmt_config: 'oxfmt --config .oxfmt.json',\n        pack: 'tsdown',\n        pack_watch: 'tsdown --watch',\n        preview: 'vite preview',\n        optimize: 'vite optimize',\n        build: 'pnpm install && vite build -r && vite run build --watch && tsdown && tsc || exit 1',\n        dev: 'vite',\n        dev_cjs: 'VITE_CJS_IGNORE_WARNING=true vite',\n        dev_cjs_cross_env: 'cross-env VITE_CJS_IGNORE_WARNING=true vite',\n        version: 'vite --version',\n        version_short: 'vite -v',\n        dev_help: 'vite --help && vite -h',\n        dev_port: 'vite --port 3000',\n        dev_host: 'vite --host 0.0.0.0',\n        dev_open: 'vite --open',\n        dev_verbose: 'vite --verbose',\n        dev_debug: 'vite --debug',\n        dev_trace: 'vite --trace',\n        dev_profile: 'vite --profile',\n        dev_stats: 'vite --stats',\n        dev_analyze: 'vite --analyze',\n        ready: 'oxlint --fix --type-aware && vitest run && tsdown && oxfmt --fix',\n        ready_env:\n          'NODE_ENV=test FOO=bar oxlint --fix --type-aware && NODE_ENV=test FOO=bar vitest run && NODE_ENV=test FOO=bar tsdown && NODE_ENV=test FOO=bar oxfmt --fix',\n        ready_new:\n          'vite install && vite fmt && vite lint --type-aware && vite test -r && vite build -r',\n      },\n      'lint-staged': {\n        '*.js': ['oxlint --fix --type-aware', 'oxfmt --fix'],\n        '*.ts': 'oxfmt --fix',\n      },\n    };\n    const extractedStagedConfig = rewritePackageJson(pkg, PackageManager.npm);\n    // lint-staged and vite-staged keys should be removed from pkg\n    expect(pkg).toMatchSnapshot();\n    // Extracted config should have rewritten commands\n    expect(extractedStagedConfig).toMatchSnapshot();\n  });\n\n  it('should rewrite devDependencies and dependencies on standalone project', async () => {\n    const pkg = {\n      devDependencies: {\n        oxlint: '1.0.0',\n        oxfmt: '1.0.0',\n      },\n      dependencies: {\n        foo: '1.0.0',\n        tsdown: '1.0.0',\n      },\n    };\n    rewritePackageJson(pkg, PackageManager.pnpm);\n    expect(pkg).toMatchSnapshot();\n  });\n\n  it('should rewrite devDependencies and dependencies on pnpm monorepo project', async () => {\n    const pkg = {\n      devDependencies: {\n        oxlint: '1.0.0',\n        oxfmt: '1.0.0',\n      },\n      dependencies: {\n        foo: '1.0.0',\n        tsdown: '1.0.0',\n      },\n    };\n    rewritePackageJson(pkg, PackageManager.pnpm, true);\n    expect(pkg).toMatchSnapshot();\n  });\n\n  it('should rewrite devDependencies and dependencies on npm monorepo project', async () => {\n    const pkg = {\n      devDependencies: {\n        oxlint: '1.0.0',\n        oxfmt: '1.0.0',\n      },\n      dependencies: {\n        foo: '1.0.0',\n        tsdown: '1.0.0',\n      },\n    };\n    rewritePackageJson(pkg, PackageManager.npm, true);\n    expect(pkg).toMatchSnapshot();\n  });\n\n  it('should rewrite devDependencies and dependencies on yarn monorepo project', async () => {\n    const pkg = {\n      devDependencies: {\n        oxlint: '1.0.0',\n        oxfmt: '1.0.0',\n      },\n      dependencies: {\n        foo: '1.0.0',\n        tsdown: '1.0.0',\n      },\n    };\n    rewritePackageJson(pkg, PackageManager.yarn, true);\n    expect(pkg).toMatchSnapshot();\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/migration/bin.ts",
    "content": "import path from 'node:path';\nimport { styleText } from 'node:util';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport mri from 'mri';\nimport semver from 'semver';\n\nimport { vitePlusHeader } from '../../binding/index.js';\nimport {\n  PackageManager,\n  type WorkspaceInfo,\n  type WorkspaceInfoOptional,\n  type WorkspacePackage,\n} from '../types/index.js';\nimport {\n  detectAgentConflicts,\n  detectExistingAgentTargetPaths,\n  selectAgentTargetPaths,\n  writeAgentInstructions,\n} from '../utils/agent.js';\nimport { isForceOverrideMode } from '../utils/constants.js';\nimport {\n  detectEditorConflicts,\n  type EditorId,\n  selectEditor,\n  writeEditorConfigs,\n} from '../utils/editor.js';\nimport { renderCliDoc } from '../utils/help.js';\nimport { hasVitePlusDependency, readNearestPackageJson } from '../utils/package.js';\nimport { displayRelative } from '../utils/path.js';\nimport {\n  cancelAndExit,\n  defaultInteractive,\n  downloadPackageManager,\n  promptGitHooks,\n  runViteInstall,\n  selectPackageManager,\n  upgradeYarn,\n} from '../utils/prompts.js';\nimport { accent, log, muted } from '../utils/terminal.js';\nimport type { PackageDependencies } from '../utils/types.js';\nimport { detectWorkspace } from '../utils/workspace.js';\nimport {\n  checkVitestVersion,\n  checkViteVersion,\n  detectEslintProject,\n  detectPrettierProject,\n  installGitHooks,\n  mergeViteConfigFiles,\n  migrateEslintToOxlint,\n  migratePrettierToOxfmt,\n  preflightGitHooksSetup,\n  rewriteMonorepo,\n  rewriteStandaloneProject,\n} from './migrator.js';\nimport { createMigrationReport, type MigrationReport } from './report.js';\n\nfunction warnPackageLevelEslint() {\n  prompts.log.warn(\n    'ESLint detected in workspace packages but no root config found. Package-level ESLint must be migrated manually.',\n  );\n}\n\nfunction warnLegacyEslintConfig(legacyConfigFile: string) {\n  prompts.log.warn(\n    `Legacy ESLint configuration detected (${legacyConfigFile}). ` +\n      'Automatic migration to Oxlint requires ESLint v9+ with flat config format (eslint.config.*). ' +\n      'Please upgrade to ESLint v9 first: https://eslint.org/docs/latest/use/migrate-to-9.0.0',\n  );\n}\n\nasync function confirmEslintMigration(interactive: boolean): Promise<boolean> {\n  if (interactive) {\n    const confirmed = await prompts.confirm({\n      message: 'Migrate ESLint rules to Oxlint using @oxlint/migrate?',\n      initialValue: true,\n    });\n    if (prompts.isCancel(confirmed)) {\n      cancelAndExit();\n    }\n    return !!confirmed;\n  }\n  return true;\n}\n\nasync function promptEslintMigration(\n  projectPath: string,\n  interactive: boolean,\n  packages?: WorkspacePackage[],\n): Promise<boolean> {\n  const eslintProject = detectEslintProject(projectPath, packages);\n  if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) {\n    warnLegacyEslintConfig(eslintProject.legacyConfigFile);\n    return false;\n  }\n  if (!eslintProject.hasDependency) {\n    return false;\n  }\n  if (!eslintProject.configFile) {\n    // Packages have eslint but no root config → warn and skip\n    warnPackageLevelEslint();\n    return false;\n  }\n  const confirmed = await confirmEslintMigration(interactive);\n  if (!confirmed) {\n    return false;\n  }\n  const ok = await migrateEslintToOxlint(\n    projectPath,\n    interactive,\n    eslintProject.configFile,\n    packages,\n  );\n  if (!ok) {\n    cancelAndExit('ESLint migration failed. Fix the issue and re-run `vp migrate`.', 1);\n  }\n  return true;\n}\n\nfunction warnPackageLevelPrettier() {\n  prompts.log.warn(\n    'Prettier detected in workspace packages but no root config found. Package-level Prettier must be migrated manually.',\n  );\n}\n\nasync function confirmPrettierMigration(interactive: boolean): Promise<boolean> {\n  if (interactive) {\n    const confirmed = await prompts.confirm({\n      message: 'Migrate Prettier to Oxfmt?',\n      initialValue: true,\n    });\n    if (prompts.isCancel(confirmed)) {\n      cancelAndExit();\n    }\n    return !!confirmed;\n  }\n  prompts.log.info('Prettier configuration detected. Auto-migrating to Oxfmt...');\n  return true;\n}\n\nasync function promptPrettierMigration(\n  projectPath: string,\n  interactive: boolean,\n  packages?: WorkspacePackage[],\n): Promise<boolean> {\n  const prettierProject = detectPrettierProject(projectPath, packages);\n  if (!prettierProject.hasDependency) {\n    return false;\n  }\n  if (!prettierProject.configFile) {\n    // Packages have prettier but no root config → warn and skip\n    warnPackageLevelPrettier();\n    return false;\n  }\n  const confirmed = await confirmPrettierMigration(interactive);\n  if (!confirmed) {\n    return false;\n  }\n  const ok = await migratePrettierToOxfmt(\n    projectPath,\n    interactive,\n    prettierProject.configFile,\n    packages,\n  );\n  if (!ok) {\n    cancelAndExit('Prettier migration failed. Fix the issue and re-run `vp migrate`.', 1);\n  }\n  return true;\n}\n\nconst helpMessage = renderCliDoc({\n  usage: 'vp migrate [PATH] [OPTIONS]',\n  summary:\n    'Migrate standalone Vite, Vitest, Oxlint, Oxfmt, and Prettier projects to unified Vite+.',\n  documentationUrl: 'https://viteplus.dev/guide/migrate',\n  sections: [\n    {\n      title: 'Arguments',\n      rows: [\n        {\n          label: 'PATH',\n          description: 'Target directory to migrate (default: current directory)',\n        },\n      ],\n    },\n    {\n      title: 'Options',\n      rows: [\n        {\n          label: '--agent NAME',\n          description:\n            'Write agent instructions file into the project (e.g. chatgpt, claude, opencode).',\n        },\n        { label: '--no-agent', description: 'Skip writing agent instructions file' },\n        {\n          label: '--editor NAME',\n          description: 'Write editor config files into the project.',\n        },\n        { label: '--no-editor', description: 'Skip writing editor config files' },\n        {\n          label: '--hooks',\n          description: 'Set up pre-commit hooks (default in non-interactive mode)',\n        },\n        { label: '--no-hooks', description: 'Skip pre-commit hooks setup' },\n        {\n          label: '--no-interactive',\n          description: 'Run in non-interactive mode (skip prompts and use defaults)',\n        },\n        { label: '-h, --help', description: 'Show this help message' },\n      ],\n    },\n    {\n      title: 'Examples',\n      lines: [\n        `  ${muted('# Migrate current package')}`,\n        `  ${accent('vp migrate')}`,\n        '',\n        `  ${muted('# Migrate specific directory')}`,\n        `  ${accent('vp migrate my-app')}`,\n        '',\n        `  ${muted('# Non-interactive mode')}`,\n        `  ${accent('vp migrate --no-interactive')}`,\n      ],\n    },\n    {\n      title: 'Migration Prompt',\n      lines: [\n        '  Give this to a coding agent when you want it to drive the migration:',\n        '',\n        '  Migrate this project to Vite+.',\n        '  Vite+ replaces the current split tooling around runtime management, package',\n        '  management, dev/build/test commands, linting, formatting, and packaging.',\n        '  Run `vp help` and `vp help migrate` before making changes.',\n        `  Use ${accent('vp migrate --no-interactive')} in the workspace root.`,\n        '  Make sure the project is using Vite 8+ and Vitest 4.1+ before migrating.',\n        '',\n        '  After the migration:',\n        '  - Confirm `vite` imports were rewritten to `vite-plus` where needed',\n        '  - Confirm `vitest` imports were rewritten to `vite-plus/test` where needed',\n        '  - Remove old `vite` and `vitest` dependencies only after those rewrites',\n        '    are confirmed',\n        '  - Move remaining tool-specific config into the appropriate blocks in',\n        '    `vite.config.ts`',\n        '',\n        '  Command mapping:',\n        '  - `vp run <script>` is the equivalent of `pnpm run <script>`',\n        '  - `vp test` runs the built-in test command, while `vp run test` runs the',\n        '    `test` script from `package.json`',\n        '  - `vp install`, `vp add`, and `vp remove` delegate through the package',\n        '    manager declared by `packageManager`',\n        '  - `vp dev`, `vp build`, `vp preview`, `vp lint`, `vp fmt`, `vp check`,',\n        '    and `vp pack` replace the corresponding standalone tools',\n        '  - Prefer `vp check` for validation loops',\n        '',\n        '  Finally, verify the migration by running:',\n        `  - ${accent('vp install')}`,\n        `  - ${accent('vp check')}`,\n        `  - ${accent('vp test')}`,\n        `  - ${accent('vp build')}`,\n        '',\n        '  Summarize the migration at the end and report any manual follow-up still',\n        '  required.',\n      ],\n    },\n  ],\n});\n\nexport interface MigrationOptions {\n  interactive: boolean;\n  help?: boolean;\n  agent?: string | string[] | false;\n  editor?: string | false;\n  hooks?: boolean;\n}\n\nfunction parseArgs() {\n  const args = process.argv.slice(3); // Skip 'node', 'vite', 'migrate'\n\n  const parsed = mri<{\n    help?: boolean;\n    interactive?: boolean;\n    agent?: string | string[] | false;\n    editor?: string | false;\n    hooks?: boolean;\n  }>(args, {\n    alias: { h: 'help' },\n    boolean: ['help', 'interactive', 'hooks'],\n    default: { interactive: defaultInteractive() },\n  });\n  const interactive = parsed.interactive;\n\n  let projectPath = parsed._[0];\n  if (projectPath) {\n    projectPath = path.resolve(process.cwd(), projectPath);\n  } else {\n    projectPath = process.cwd();\n  }\n\n  return {\n    projectPath,\n    options: {\n      interactive,\n      help: parsed.help,\n      agent: parsed.agent,\n      editor: parsed.editor,\n      hooks: parsed.hooks,\n    } as MigrationOptions,\n  };\n}\n\ninterface MigrationPlan {\n  packageManager: PackageManager;\n  shouldSetupHooks: boolean;\n  selectedAgentTargetPaths?: string[];\n  agentConflictDecisions: Map<string, 'append' | 'skip'>;\n  selectedEditor?: EditorId;\n  editorConflictDecisions: Map<string, 'merge' | 'skip'>;\n  migrateEslint: boolean;\n  eslintConfigFile?: string;\n  migratePrettier: boolean;\n  prettierConfigFile?: string;\n}\n\nasync function collectMigrationPlan(\n  rootDir: string,\n  detectedPackageManager: PackageManager | undefined,\n  options: MigrationOptions,\n  packages?: WorkspacePackage[],\n): Promise<MigrationPlan> {\n  // 1. Package manager selection\n  const packageManager =\n    detectedPackageManager ?? (await selectPackageManager(options.interactive, true));\n\n  // 2. Git hooks (including preflight check)\n  let shouldSetupHooks = await promptGitHooks(options);\n  if (shouldSetupHooks) {\n    const reason = preflightGitHooksSetup(rootDir);\n    if (reason) {\n      prompts.log.warn(`⚠ ${reason}`);\n      shouldSetupHooks = false;\n    }\n  }\n\n  // 3. Agent selection (auto-detect existing agent files to skip the selector prompt)\n  const existingAgentTargetPaths =\n    options.agent !== undefined || !options.interactive\n      ? undefined\n      : detectExistingAgentTargetPaths(rootDir);\n  const selectedAgentTargetPaths =\n    existingAgentTargetPaths !== undefined\n      ? existingAgentTargetPaths\n      : await selectAgentTargetPaths({\n          interactive: options.interactive,\n          agent: options.agent,\n          onCancel: () => cancelAndExit(),\n        });\n\n  // 4. Agent conflict detection + prompting\n  const agentConflicts = await detectAgentConflicts({\n    projectRoot: rootDir,\n    targetPaths: selectedAgentTargetPaths,\n  });\n  const agentConflictDecisions = new Map<string, 'append' | 'skip'>();\n  for (const conflict of agentConflicts) {\n    if (options.interactive) {\n      const action = await prompts.select({\n        message: `Agent instructions already exist at ${conflict.targetPath}.`,\n        options: [\n          { label: 'Append', value: 'append' as const, hint: 'Add template content to the end' },\n          { label: 'Skip', value: 'skip' as const, hint: 'Leave existing file unchanged' },\n        ],\n        initialValue: 'skip' as const,\n      });\n      if (prompts.isCancel(action)) {\n        cancelAndExit();\n      }\n      agentConflictDecisions.set(conflict.targetPath, action);\n    } else {\n      agentConflictDecisions.set(conflict.targetPath, 'skip');\n    }\n  }\n\n  // 5. Editor selection\n  const selectedEditor = await selectEditor({\n    interactive: options.interactive,\n    editor: options.editor,\n    onCancel: () => cancelAndExit(),\n  });\n\n  // 6. Editor conflict detection + prompting\n  const editorConflicts = detectEditorConflicts({\n    projectRoot: rootDir,\n    editorId: selectedEditor,\n  });\n  const editorConflictDecisions = new Map<string, 'merge' | 'skip'>();\n  for (const conflict of editorConflicts) {\n    if (options.interactive) {\n      const action = await prompts.select({\n        message: `${conflict.displayPath} already exists.`,\n        options: [\n          {\n            label: 'Merge',\n            value: 'merge' as const,\n            hint: 'Merge new settings into existing file',\n          },\n          { label: 'Skip', value: 'skip' as const, hint: 'Leave existing file unchanged' },\n        ],\n        initialValue: 'skip' as const,\n      });\n      if (prompts.isCancel(action)) {\n        cancelAndExit();\n      }\n      editorConflictDecisions.set(conflict.fileName, action);\n    } else {\n      editorConflictDecisions.set(conflict.fileName, 'merge');\n    }\n  }\n\n  // 7. ESLint detection + prompt\n  const eslintProject = detectEslintProject(rootDir, packages);\n  let migrateEslint = false;\n  if (eslintProject.hasDependency && !eslintProject.configFile && eslintProject.legacyConfigFile) {\n    warnLegacyEslintConfig(eslintProject.legacyConfigFile);\n  } else if (eslintProject.hasDependency && eslintProject.configFile) {\n    migrateEslint = await confirmEslintMigration(options.interactive);\n  } else if (eslintProject.hasDependency) {\n    warnPackageLevelEslint();\n  }\n\n  // 9. Prettier detection + prompt\n  const prettierProject = detectPrettierProject(rootDir, packages);\n  let migratePrettier = false;\n  if (prettierProject.hasDependency && prettierProject.configFile) {\n    migratePrettier = await confirmPrettierMigration(options.interactive);\n  } else if (prettierProject.hasDependency) {\n    warnPackageLevelPrettier();\n  }\n\n  const plan: MigrationPlan = {\n    packageManager,\n    shouldSetupHooks,\n    selectedAgentTargetPaths,\n    agentConflictDecisions,\n    selectedEditor,\n    editorConflictDecisions,\n    migrateEslint,\n    eslintConfigFile: eslintProject.configFile,\n    migratePrettier,\n    prettierConfigFile: prettierProject.configFile,\n  };\n\n  return plan;\n}\n\nfunction formatDuration(durationMs: number) {\n  if (durationMs < 1000) {\n    return `${Math.max(1, durationMs)}ms`;\n  }\n  const durationSeconds = durationMs / 1000;\n  if (durationSeconds < 10) {\n    return `${durationSeconds.toFixed(1)}s`;\n  }\n  return `${Math.round(durationSeconds)}s`;\n}\n\nfunction showMigrationSummary(options: {\n  projectRoot: string;\n  packageManager: string;\n  packageManagerVersion: string;\n  installDurationMs: number;\n  report: MigrationReport;\n  updatedExistingVitePlus?: boolean;\n}) {\n  const {\n    projectRoot,\n    packageManager,\n    packageManagerVersion,\n    installDurationMs,\n    report,\n    updatedExistingVitePlus,\n  } = options;\n  const projectLabel = displayRelative(projectRoot) || '.';\n  const configUpdates =\n    report.createdViteConfigCount +\n    report.mergedConfigCount +\n    report.mergedStagedConfigCount +\n    report.inlinedLintStagedConfigCount +\n    report.removedConfigCount +\n    report.tsdownImportCount;\n\n  log(\n    `${styleText('magenta', '◇')} ${updatedExistingVitePlus ? 'Updated' : 'Migrated'} ${accent(projectLabel)}${\n      updatedExistingVitePlus ? '' : ' to Vite+'\n    }`,\n  );\n  log(\n    `${styleText('gray', '•')} Node ${process.versions.node}  ${packageManager} ${packageManagerVersion}`,\n  );\n  if (installDurationMs > 0) {\n    log(\n      `${styleText('green', '✓')} Dependencies installed in ${formatDuration(installDurationMs)}`,\n    );\n  }\n  if (configUpdates > 0 || report.rewrittenImportFileCount > 0) {\n    const parts: string[] = [];\n    if (configUpdates > 0) {\n      parts.push(\n        `${configUpdates} ${configUpdates === 1 ? 'config update' : 'config updates'} applied`,\n      );\n    }\n    if (report.rewrittenImportFileCount > 0) {\n      parts.push(\n        `${report.rewrittenImportFileCount} ${\n          report.rewrittenImportFileCount === 1 ? 'file had' : 'files had'\n        } imports rewritten`,\n      );\n    }\n    log(`${styleText('gray', '•')} ${parts.join(', ')}`);\n  }\n  if (report.eslintMigrated) {\n    log(`${styleText('gray', '•')} ESLint rules migrated to Oxlint`);\n  }\n  if (report.prettierMigrated) {\n    log(`${styleText('gray', '•')} Prettier migrated to Oxfmt`);\n  }\n  if (report.gitHooksConfigured) {\n    log(`${styleText('gray', '•')} Git hooks configured`);\n  }\n  if (report.warnings.length > 0) {\n    log(`${styleText('yellow', '!')} Warnings:`);\n    for (const warning of report.warnings) {\n      log(`  - ${warning}`);\n    }\n  }\n  if (report.manualSteps.length > 0) {\n    log(`${styleText('blue', '→')} Manual follow-up:`);\n    for (const step of report.manualSteps) {\n      log(`  - ${step}`);\n    }\n  }\n}\n\nasync function checkRolldownCompatibility(rootDir: string, report: MigrationReport): Promise<void> {\n  try {\n    const { resolveConfig } = await import('../index.js');\n    const { checkManualChunksCompat } = await import('./compat.js');\n    // Use 'runner' configLoader to avoid Rolldown bundling the config file,\n    // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel.\n    const config = await resolveConfig(\n      { root: rootDir, logLevel: 'silent', configLoader: 'runner' },\n      'build',\n    );\n    checkManualChunksCompat(config.build?.rollupOptions?.output, report);\n  } catch {\n    // Config resolution may fail — skip compatibility check silently\n  }\n}\n\nasync function executeMigrationPlan(\n  workspaceInfoOptional: WorkspaceInfoOptional,\n  plan: MigrationPlan,\n  interactive: boolean,\n): Promise<{\n  installDurationMs: number;\n  packageManagerVersion: string;\n  report: MigrationReport;\n}> {\n  const report = createMigrationReport();\n  const migrationProgress = interactive ? prompts.spinner({ indicator: 'timer' }) : undefined;\n  let migrationProgressStarted = false;\n  const updateMigrationProgress = (message: string) => {\n    if (!migrationProgress) {\n      return;\n    }\n    if (migrationProgressStarted) {\n      migrationProgress.message(message);\n      return;\n    }\n    migrationProgress.start(message);\n    migrationProgressStarted = true;\n  };\n  const clearMigrationProgress = () => {\n    if (migrationProgress && migrationProgressStarted) {\n      migrationProgress.clear();\n      migrationProgressStarted = false;\n    }\n  };\n  const failMigrationProgress = (message: string) => {\n    if (migrationProgress && migrationProgressStarted) {\n      migrationProgress.error(message);\n      migrationProgressStarted = false;\n    }\n  };\n\n  // 1. Download package manager + version validation\n  updateMigrationProgress('Preparing migration');\n  const downloadResult = await downloadPackageManager(\n    plan.packageManager,\n    workspaceInfoOptional.packageManagerVersion,\n    interactive,\n    true,\n  );\n  const workspaceInfo: WorkspaceInfo = {\n    ...workspaceInfoOptional,\n    packageManager: plan.packageManager,\n    downloadPackageManager: downloadResult,\n  };\n\n  // 2. Upgrade yarn if needed, or validate PM version\n  if (\n    plan.packageManager === PackageManager.yarn &&\n    semver.satisfies(downloadResult.version, '>=4.0.0 <4.10.0')\n  ) {\n    updateMigrationProgress('Upgrading Yarn');\n    await upgradeYarn(workspaceInfo.rootDir, interactive, true);\n  } else if (\n    plan.packageManager === PackageManager.pnpm &&\n    semver.satisfies(downloadResult.version, '< 9.5.0')\n  ) {\n    failMigrationProgress('Migration failed');\n    prompts.log.error(\n      `✘ pnpm@${downloadResult.version} is not supported by auto migration, please upgrade pnpm to >=9.5.0 first`,\n    );\n    cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1);\n  } else if (\n    plan.packageManager === PackageManager.npm &&\n    semver.satisfies(downloadResult.version, '< 8.3.0')\n  ) {\n    failMigrationProgress('Migration failed');\n    prompts.log.error(\n      `✘ npm@${downloadResult.version} is not supported by auto migration, please upgrade npm to >=8.3.0 first`,\n    );\n    cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1);\n  }\n\n  // 3. Run vp install to ensure the project is ready\n  updateMigrationProgress('Installing dependencies');\n  const initialInstallSummary = await runViteInstall(\n    workspaceInfo.rootDir,\n    interactive,\n    undefined,\n    {\n      silent: true,\n    },\n  );\n\n  // 4. Check vite and vitest version is supported by migration\n  updateMigrationProgress('Validating toolchain');\n  const isViteSupported = checkViteVersion(workspaceInfo.rootDir);\n  const isVitestSupported = checkVitestVersion(workspaceInfo.rootDir);\n  if (!isViteSupported || !isVitestSupported) {\n    failMigrationProgress('Migration failed');\n    cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1);\n  }\n\n  // 5. Check for Rolldown-incompatible config patterns (root + workspace packages)\n  updateMigrationProgress('Checking config compatibility');\n  await checkRolldownCompatibility(workspaceInfo.rootDir, report);\n  if (workspaceInfo.packages) {\n    for (const pkg of workspaceInfo.packages) {\n      await checkRolldownCompatibility(path.join(workspaceInfo.rootDir, pkg.path), report);\n    }\n  }\n\n  // 6. ESLint → Oxlint migration (before main rewrite so .oxlintrc.json gets picked up)\n  if (plan.migrateEslint) {\n    updateMigrationProgress('Migrating ESLint');\n    const eslintOk = await migrateEslintToOxlint(\n      workspaceInfo.rootDir,\n      interactive,\n      plan.eslintConfigFile,\n      workspaceInfo.packages,\n      { silent: true, report },\n    );\n    if (!eslintOk) {\n      failMigrationProgress('Migration failed');\n      cancelAndExit('ESLint migration failed. Fix the issue and re-run `vp migrate`.', 1);\n    }\n  }\n\n  // 5b. Prettier → Oxfmt migration (before main rewrite so .oxfmtrc.json gets picked up)\n  if (plan.migratePrettier) {\n    updateMigrationProgress('Migrating Prettier');\n    const prettierOk = await migratePrettierToOxfmt(\n      workspaceInfo.rootDir,\n      interactive,\n      plan.prettierConfigFile,\n      workspaceInfo.packages,\n      { silent: true, report },\n    );\n    if (!prettierOk) {\n      failMigrationProgress('Migration failed');\n      cancelAndExit('Prettier migration failed. Fix the issue and re-run `vp migrate`.', 1);\n    }\n  }\n\n  // 6. Skip staged migration when hooks are disabled (--no-hooks or preflight failed).\n  // Without hooks, lint-staged config must stay in package.json so existing\n  // .husky/pre-commit scripts that invoke `npx lint-staged` keep working.\n  const skipStagedMigration = !plan.shouldSetupHooks;\n\n  // 7. Rewrite configs\n  updateMigrationProgress('Rewriting configs');\n  if (workspaceInfo.isMonorepo) {\n    rewriteMonorepo(workspaceInfo, skipStagedMigration, true, report);\n  } else {\n    rewriteStandaloneProject(\n      workspaceInfo.rootDir,\n      workspaceInfo,\n      skipStagedMigration,\n      true,\n      report,\n    );\n  }\n\n  // 8. Install git hooks\n  if (plan.shouldSetupHooks) {\n    updateMigrationProgress('Configuring git hooks');\n    installGitHooks(workspaceInfo.rootDir, true, report);\n  }\n\n  // 9. Write agent instructions (using pre-resolved decisions)\n  updateMigrationProgress('Writing agent instructions');\n  await writeAgentInstructions({\n    projectRoot: workspaceInfo.rootDir,\n    targetPaths: plan.selectedAgentTargetPaths,\n    interactive,\n    conflictDecisions: plan.agentConflictDecisions,\n    silent: true,\n  });\n\n  // 10. Write editor configs (using pre-resolved decisions)\n  updateMigrationProgress('Writing editor configs');\n  await writeEditorConfigs({\n    projectRoot: workspaceInfo.rootDir,\n    editorId: plan.selectedEditor,\n    interactive,\n    conflictDecisions: plan.editorConflictDecisions,\n    silent: true,\n  });\n\n  // 11. Reinstall after migration\n  // npm needs --force to re-resolve packages with newly added overrides,\n  // otherwise the stale lockfile prevents override resolution.\n  const installArgs = plan.packageManager === PackageManager.npm ? ['--force'] : undefined;\n  updateMigrationProgress('Installing dependencies');\n  const finalInstallSummary = await runViteInstall(\n    workspaceInfo.rootDir,\n    interactive,\n    installArgs,\n    { silent: true },\n  );\n\n  clearMigrationProgress();\n  return {\n    installDurationMs: initialInstallSummary.durationMs + finalInstallSummary.durationMs,\n    packageManagerVersion: downloadResult.version,\n    report,\n  };\n}\n\nasync function main() {\n  const { projectPath, options } = parseArgs();\n\n  if (options.help) {\n    log(vitePlusHeader() + '\\n');\n    log(helpMessage);\n    return;\n  }\n\n  log(`${vitePlusHeader()}\\n`);\n\n  const workspaceInfoOptional = await detectWorkspace(projectPath);\n  const resolvedPackageManager = workspaceInfoOptional.packageManager ?? 'unknown';\n\n  // Early return if already using Vite+ (only ESLint/hooks migration may be needed)\n  // In force-override mode (file: tgz overrides), skip this check and run full migration\n  const rootPkg = readNearestPackageJson<PackageDependencies>(workspaceInfoOptional.rootDir);\n  if (hasVitePlusDependency(rootPkg) && !isForceOverrideMode()) {\n    let didMigrate = false;\n    let installDurationMs = 0;\n    const report = createMigrationReport();\n    const migrationProgress = options.interactive\n      ? prompts.spinner({ indicator: 'timer' })\n      : undefined;\n    let migrationProgressStarted = false;\n    const updateMigrationProgress = (message: string) => {\n      if (!migrationProgress) {\n        return;\n      }\n      if (migrationProgressStarted) {\n        migrationProgress.message(message);\n        return;\n      }\n      migrationProgress.start(message);\n      migrationProgressStarted = true;\n    };\n    const clearMigrationProgress = () => {\n      if (migrationProgress && migrationProgressStarted) {\n        migrationProgress.clear();\n        migrationProgressStarted = false;\n      }\n    };\n\n    // Check if ESLint migration is needed\n    const eslintMigrated = await promptEslintMigration(\n      workspaceInfoOptional.rootDir,\n      options.interactive,\n      workspaceInfoOptional.packages,\n    );\n\n    // Check if Prettier migration is needed\n    const prettierMigrated = await promptPrettierMigration(\n      workspaceInfoOptional.rootDir,\n      options.interactive,\n      workspaceInfoOptional.packages,\n    );\n\n    // Merge configs and reinstall once if any tool migration happened\n    if (eslintMigrated || prettierMigrated) {\n      updateMigrationProgress('Rewriting configs');\n      mergeViteConfigFiles(workspaceInfoOptional.rootDir, true, report);\n      updateMigrationProgress('Installing dependencies');\n      const installSummary = await runViteInstall(\n        workspaceInfoOptional.rootDir,\n        options.interactive,\n        undefined,\n        {\n          silent: true,\n        },\n      );\n      installDurationMs += installSummary.durationMs;\n      didMigrate = true;\n      report.eslintMigrated = eslintMigrated;\n      report.prettierMigrated = prettierMigrated;\n    }\n\n    // Check if husky/lint-staged migration is needed\n    const hasHooksToMigrate =\n      rootPkg?.devDependencies?.husky ||\n      rootPkg?.dependencies?.husky ||\n      rootPkg?.devDependencies?.['lint-staged'] ||\n      rootPkg?.dependencies?.['lint-staged'];\n    if (hasHooksToMigrate) {\n      const shouldSetupHooks = await promptGitHooks(options);\n      if (shouldSetupHooks) {\n        updateMigrationProgress('Configuring git hooks');\n      }\n      if (shouldSetupHooks && installGitHooks(workspaceInfoOptional.rootDir, true, report)) {\n        didMigrate = true;\n      }\n    }\n\n    // Check for Rolldown-incompatible config patterns (root + workspace packages)\n    await checkRolldownCompatibility(workspaceInfoOptional.rootDir, report);\n    if (workspaceInfoOptional.packages) {\n      for (const pkg of workspaceInfoOptional.packages) {\n        await checkRolldownCompatibility(\n          path.join(workspaceInfoOptional.rootDir, pkg.path),\n          report,\n        );\n      }\n    }\n\n    if (didMigrate || report.warnings.length > 0) {\n      clearMigrationProgress();\n      showMigrationSummary({\n        projectRoot: workspaceInfoOptional.rootDir,\n        packageManager: resolvedPackageManager,\n        packageManagerVersion: workspaceInfoOptional.packageManagerVersion,\n        installDurationMs,\n        report,\n        updatedExistingVitePlus: true,\n      });\n    } else {\n      prompts.outro(`This project is already using Vite+! ${accent(`Happy coding!`)}`);\n    }\n    return;\n  }\n\n  // Phase 1: Collect all user decisions upfront\n  const plan = await collectMigrationPlan(\n    workspaceInfoOptional.rootDir,\n    workspaceInfoOptional.packageManager,\n    options,\n    workspaceInfoOptional.packages,\n  );\n\n  // Phase 2: Execute without prompts\n  const result = await executeMigrationPlan(workspaceInfoOptional, plan, options.interactive);\n  showMigrationSummary({\n    projectRoot: workspaceInfoOptional.rootDir,\n    packageManager: plan.packageManager,\n    packageManagerVersion: result.packageManagerVersion,\n    installDurationMs: result.installDurationMs,\n    report: result.report,\n  });\n}\n\nmain().catch((err) => {\n  prompts.log.error(err.message);\n  console.error(err);\n  process.exit(1);\n});\n"
  },
  {
    "path": "packages/cli/src/migration/compat.ts",
    "content": "import { addMigrationWarning, type MigrationReport } from './report.js';\n\n/**\n * Check for Rolldown-incompatible manualChunks config patterns.\n */\nexport function checkManualChunksCompat(output: unknown, report: MigrationReport): void {\n  const outputs = Array.isArray(output) ? output : output ? [output] : [];\n  for (const out of outputs) {\n    if (out.manualChunks != null && typeof out.manualChunks !== 'function') {\n      addMigrationWarning(\n        report,\n        'Object-form `build.rollupOptions.output.manualChunks` is not supported by Rolldown. ' +\n          'Convert it to function form or use `build.rolldownOptions.output.codeSplitting`. ' +\n          'See: https://rolldown.rs/options/output#manualchunks and https://rolldown.rs/in-depth/manual-code-splitting',\n      );\n      break;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/migration/detector.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nexport interface ConfigFiles {\n  viteConfig?: string;\n  vitestConfig?: string;\n  tsdownConfig?: string;\n  oxlintConfig?: string;\n  oxfmtConfig?: string;\n  eslintConfig?: string;\n  eslintLegacyConfig?: string;\n  prettierConfig?: string; // e.g. '.prettierrc.json', 'prettier.config.js', PRETTIER_PACKAGE_JSON_CONFIG\n  prettierIgnore?: boolean;\n}\n\n// Sentinel value indicating Prettier config lives inside package.json \"prettier\" key.\nexport const PRETTIER_PACKAGE_JSON_CONFIG = 'package.json#prettier';\n\n// All known Prettier config file names (standalone files only).\n// https://prettier.io/docs/configuration\nexport const PRETTIER_CONFIG_FILES = [\n  '.prettierrc',\n  '.prettierrc.json',\n  '.prettierrc.jsonc',\n  '.prettierrc.yaml',\n  '.prettierrc.yml',\n  '.prettierrc.toml',\n  '.prettierrc.js',\n  '.prettierrc.cjs',\n  '.prettierrc.mjs',\n  '.prettierrc.ts',\n  '.prettierrc.cts',\n  '.prettierrc.mts',\n  'prettier.config.js',\n  'prettier.config.cjs',\n  'prettier.config.mjs',\n  'prettier.config.ts',\n  'prettier.config.cts',\n  'prettier.config.mts',\n] as const;\n\nexport function detectConfigs(projectPath: string): ConfigFiles {\n  const configs: ConfigFiles = {};\n\n  // Check for vite.config.*\n  // https://vite.dev/config/\n  const viteConfigs = [\n    'vite.config.ts',\n    'vite.config.mts',\n    'vite.config.cts',\n    'vite.config.js',\n    'vite.config.mjs',\n    'vite.config.cjs',\n  ];\n  for (const config of viteConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.viteConfig = config;\n      break;\n    }\n  }\n\n  // Check for vitest.config.*\n  // https://vitest.dev/config/\n  const vitestConfigs = [\n    'vitest.config.ts',\n    'vitest.config.mts',\n    'vitest.config.cts',\n    'vitest.config.js',\n    'vitest.config.mjs',\n    'vitest.config.cjs',\n  ];\n  for (const config of vitestConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.vitestConfig = config;\n      break;\n    }\n  }\n\n  // Check for tsdown.config.*\n  // https://tsdown.dev/options/config-file\n  const tsdownConfigs = [\n    'tsdown.config.ts',\n    'tsdown.config.mts',\n    'tsdown.config.cts',\n    'tsdown.config.js',\n    'tsdown.config.mjs',\n    'tsdown.config.cjs',\n    'tsdown.config.json',\n    'tsdown.config',\n  ];\n  // Additionally, you can define your configuration directly in the `tsdown` field of your package.json file\n  for (const config of tsdownConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.tsdownConfig = config;\n      break;\n    }\n  }\n\n  // Check for oxlint configs\n  // https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format\n  const oxlintConfigs = ['.oxlintrc.json'];\n  for (const config of oxlintConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.oxlintConfig = config;\n      break;\n    }\n  }\n\n  // Check for oxfmt configs\n  // https://oxc.rs/docs/guide/usage/formatter.html#configuration-file\n  const oxfmtConfigs = ['.oxfmtrc.json', '.oxfmtrc.jsonc'];\n  for (const config of oxfmtConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.oxfmtConfig = config;\n      break;\n    }\n  }\n\n  // Check for eslint configs (flat config only)\n  // https://eslint.org/docs/latest/use/configure/configuration-files\n  const eslintConfigs = [\n    'eslint.config.js',\n    'eslint.config.mjs',\n    'eslint.config.cjs',\n    'eslint.config.ts',\n    'eslint.config.mts',\n    'eslint.config.cts',\n  ];\n  for (const config of eslintConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.eslintConfig = config;\n      break;\n    }\n  }\n\n  // Check for legacy eslint configs (.eslintrc*)\n  // https://eslint.org/docs/latest/use/configure/configuration-files-deprecated\n  const eslintLegacyConfigs = [\n    '.eslintrc',\n    '.eslintrc.json',\n    '.eslintrc.js',\n    '.eslintrc.cjs',\n    '.eslintrc.yaml',\n    '.eslintrc.yml',\n  ];\n  for (const config of eslintLegacyConfigs) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.eslintLegacyConfig = config;\n      break;\n    }\n  }\n\n  // Check for prettier configs\n  for (const config of PRETTIER_CONFIG_FILES) {\n    if (fs.existsSync(path.join(projectPath, config))) {\n      configs.prettierConfig = config;\n      break;\n    }\n  }\n  // Check for \"prettier\" key in package.json if no config file found\n  if (!configs.prettierConfig) {\n    const packageJsonPath = path.join(projectPath, 'package.json');\n    if (fs.existsSync(packageJsonPath)) {\n      try {\n        const content = fs.readFileSync(packageJsonPath, 'utf8');\n        const pkg = JSON.parse(content);\n        if (pkg.prettier) {\n          configs.prettierConfig = PRETTIER_PACKAGE_JSON_CONFIG;\n        }\n      } catch {\n        // ignore parse errors\n      }\n    }\n  }\n\n  // Check for .prettierignore\n  if (fs.existsSync(path.join(projectPath, '.prettierignore'))) {\n    configs.prettierIgnore = true;\n  }\n\n  return configs;\n}\n"
  },
  {
    "path": "packages/cli/src/migration/migrator.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport spawn from 'cross-spawn';\nimport semver from 'semver';\nimport { Scalar, YAMLMap, YAMLSeq } from 'yaml';\n\nimport {\n  mergeJsonConfig,\n  mergeTsdownConfig,\n  rewriteEslint,\n  rewritePrettier,\n  rewriteScripts,\n  rewriteImportsInDirectory,\n  type DownloadPackageManagerResult,\n} from '../../binding/index.js';\nimport { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../types/index.js';\nimport { runCommandSilently } from '../utils/command.js';\nimport {\n  BASEURL_TSCONFIG_WARNING,\n  VITE_PLUS_NAME,\n  VITE_PLUS_OVERRIDE_PACKAGES,\n  VITE_PLUS_VERSION,\n  isForceOverrideMode,\n} from '../utils/constants.js';\nimport { editJsonFile, isJsonFile, readJsonFile } from '../utils/json.js';\nimport { detectPackageMetadata } from '../utils/package.js';\nimport { displayRelative, rulesDir } from '../utils/path.js';\nimport { getSpinner } from '../utils/prompts.js';\nimport { hasBaseUrlInTsconfig } from '../utils/tsconfig.js';\nimport { editYamlFile, scalarString, type YamlDocument } from '../utils/yaml.js';\nimport {\n  PRETTIER_CONFIG_FILES,\n  PRETTIER_PACKAGE_JSON_CONFIG,\n  detectConfigs,\n  type ConfigFiles,\n} from './detector.js';\nimport { addManualStep, addMigrationWarning, type MigrationReport } from './report.js';\n\n// All known lint-staged config file names.\n// JSON-parseable ones come first so rewriteLintStagedConfigFile can rewrite them.\nconst LINT_STAGED_JSON_CONFIG_FILES = ['.lintstagedrc.json', '.lintstagedrc'] as const;\nconst LINT_STAGED_OTHER_CONFIG_FILES = [\n  '.lintstagedrc.yaml',\n  '.lintstagedrc.yml',\n  '.lintstagedrc.mjs',\n  'lint-staged.config.mjs',\n  '.lintstagedrc.cjs',\n  'lint-staged.config.cjs',\n  '.lintstagedrc.js',\n  'lint-staged.config.js',\n  '.lintstagedrc.ts',\n  'lint-staged.config.ts',\n  '.lintstagedrc.mts',\n  'lint-staged.config.mts',\n  '.lintstagedrc.cts',\n  'lint-staged.config.cts',\n] as const;\nconst LINT_STAGED_ALL_CONFIG_FILES = [\n  ...LINT_STAGED_JSON_CONFIG_FILES,\n  ...LINT_STAGED_OTHER_CONFIG_FILES,\n] as const;\n\n// packages that are replaced with vite-plus\nconst REMOVE_PACKAGES = [\n  'oxlint',\n  'oxlint-tsgolint',\n  'oxfmt',\n  'tsdown',\n  '@vitest/browser',\n  '@vitest/browser-preview',\n  '@vitest/browser-playwright',\n  '@vitest/browser-webdriverio',\n] as const;\n\nfunction warnMigration(message: string, report?: MigrationReport) {\n  addMigrationWarning(report, message);\n  if (!report) {\n    prompts.log.warn(message);\n  }\n}\n\nfunction infoMigration(message: string, report?: MigrationReport) {\n  addManualStep(report, message);\n  if (!report) {\n    prompts.log.info(message);\n  }\n}\n\nexport function checkViteVersion(projectPath: string): boolean {\n  return checkPackageVersion(projectPath, 'vite', '7.0.0');\n}\n\nexport function checkVitestVersion(projectPath: string): boolean {\n  return checkPackageVersion(projectPath, 'vitest', '4.0.0');\n}\n\n/**\n * Check the package version is supported by auto migration\n * @param projectPath - The path to the project\n * @param name - The name of the package\n * @param minVersion - The minimum version of the package\n * @returns true if the package version is supported by auto migration\n */\nfunction checkPackageVersion(projectPath: string, name: string, minVersion: string): boolean {\n  const metadata = detectPackageMetadata(projectPath, name);\n  if (!metadata || metadata.name !== name) {\n    return true;\n  }\n  if (semver.satisfies(metadata.version, `<${minVersion}`)) {\n    const packageJsonFilePath = path.join(projectPath, 'package.json');\n    prompts.log.error(\n      `✘ ${name}@${metadata.version} in ${displayRelative(packageJsonFilePath)} is not supported by auto migration`,\n    );\n    prompts.log.info(`Please upgrade ${name} to version >=${minVersion} first`);\n    return false;\n  }\n  return true;\n}\n\nexport function detectEslintProject(\n  projectPath: string,\n  packages?: WorkspacePackage[],\n): {\n  hasDependency: boolean;\n  configFile?: string;\n  legacyConfigFile?: string;\n} {\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return { hasDependency: false };\n  }\n  const pkg = readJsonFile<{\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n  }>(packageJsonPath);\n  let hasDependency = !!(pkg.devDependencies?.eslint || pkg.dependencies?.eslint);\n  const configs = detectConfigs(projectPath);\n  let configFile = configs.eslintConfig;\n  const legacyConfigFile = configs.eslintLegacyConfig;\n\n  // If root doesn't have eslint dependency, check workspace packages\n  if (!hasDependency && packages) {\n    for (const wp of packages) {\n      const pkgJsonPath = path.join(projectPath, wp.path, 'package.json');\n      if (!fs.existsSync(pkgJsonPath)) {\n        continue;\n      }\n      const wpPkg = readJsonFile<{\n        devDependencies?: Record<string, string>;\n        dependencies?: Record<string, string>;\n      }>(pkgJsonPath);\n      if (wpPkg.devDependencies?.eslint || wpPkg.dependencies?.eslint) {\n        hasDependency = true;\n        break;\n      }\n    }\n  }\n\n  return { hasDependency, configFile, legacyConfigFile };\n}\n\n/**\n * Run a `vp dlx @oxlint/migrate` step with graceful error handling.\n * Returns true on success, false on failure (spawn error or non-zero exit).\n */\nasync function runOxlintMigrateStep(\n  vpBin: string,\n  cwd: string,\n  migratePackage: string,\n  args: string[],\n  spinner: ReturnType<typeof getSpinner>,\n  failMessage: string,\n  manualHint: string,\n): Promise<boolean> {\n  try {\n    const result = await runCommandSilently({\n      command: vpBin,\n      args: ['dlx', migratePackage, ...args],\n      cwd,\n      envs: process.env,\n    });\n    if (result.exitCode !== 0) {\n      spinner.stop(failMessage);\n      const stderr = result.stderr.toString().trim();\n      if (stderr) {\n        prompts.log.warn(`⚠ ${stderr}`);\n      }\n      prompts.log.info(manualHint);\n      return false;\n    }\n    return true;\n  } catch {\n    spinner.stop(failMessage);\n    prompts.log.info(manualHint);\n    return false;\n  }\n}\n\nexport async function migrateEslintToOxlint(\n  projectPath: string,\n  interactive: boolean,\n  eslintConfigFile?: string,\n  packages?: WorkspacePackage[],\n  options?: { silent?: boolean; report?: MigrationReport },\n): Promise<boolean> {\n  const vpBin = process.env.VITE_PLUS_CLI_BIN ?? 'vp';\n  const spinner = options?.silent\n    ? {\n        start: () => {},\n        stop: () => {},\n        pause: () => {},\n        resume: () => {},\n        cancel: () => {},\n        error: () => {},\n        clear: () => {},\n        message: () => {},\n        isCancelled: false,\n      }\n    : getSpinner(interactive);\n\n  // Steps 1-2: Only run @oxlint/migrate if there's an eslint config at root\n  if (eslintConfigFile) {\n    const migratePackage = '@oxlint/migrate';\n\n    // Step 1: Generate .oxlintrc.json from ESLint config\n    spinner.start('Migrating ESLint config to Oxlint...');\n    const migrateOk = await runOxlintMigrateStep(\n      vpBin,\n      projectPath,\n      migratePackage,\n      ['--merge', '--type-aware', '--with-nursery', '--details'],\n      spinner,\n      'ESLint migration failed',\n      `You can run \\`vp dlx ${migratePackage} --merge --type-aware --with-nursery --details\\` manually later`,\n    );\n    if (!migrateOk) {\n      return false;\n    }\n    spinner.stop('ESLint config migrated to .oxlintrc.json');\n\n    // Step 2: Replace eslint-disable comments with oxlint-disable\n    spinner.start('Replacing ESLint comments with Oxlint equivalents...');\n    const replaceOk = await runOxlintMigrateStep(\n      vpBin,\n      projectPath,\n      migratePackage,\n      ['--replace-eslint-comments'],\n      spinner,\n      'ESLint comment replacement failed',\n      `You can run \\`vp dlx ${migratePackage} --replace-eslint-comments\\` manually later`,\n    );\n    if (replaceOk) {\n      spinner.stop('ESLint comments replaced');\n    }\n    // Continue with cleanup regardless — .oxlintrc.json was generated successfully\n  }\n\n  if (options?.report) {\n    options.report.eslintMigrated = true;\n  }\n\n  // Step 3: Delete all eslint config files at root\n  deleteEslintConfigFiles(projectPath, options?.report, options?.silent);\n\n  // Step 4: Remove eslint dependency and rewrite eslint scripts (root only)\n  rewriteEslintPackageJson(path.join(projectPath, 'package.json'));\n\n  // Step 4b: Rewrite eslint scripts in workspace packages\n  if (packages) {\n    for (const pkg of packages) {\n      rewriteEslintPackageJson(path.join(projectPath, pkg.path, 'package.json'));\n    }\n  }\n\n  // Step 5: Rewrite eslint references in lint-staged config files\n  rewriteEslintLintStagedConfigFiles(projectPath, options?.report);\n\n  return true;\n}\n\nfunction deleteEslintConfigFiles(basePath: string, report?: MigrationReport, silent = false): void {\n  const configs = detectConfigs(basePath);\n  for (const file of [configs.eslintConfig, configs.eslintLegacyConfig]) {\n    if (file) {\n      const configPath = path.join(basePath, file);\n      if (fs.existsSync(configPath)) {\n        fs.unlinkSync(configPath);\n        if (report) {\n          report.removedConfigCount++;\n        }\n        if (!silent) {\n          prompts.log.success(`✔ Removed ${displayRelative(configPath)}`);\n        }\n      }\n    }\n  }\n}\n\nfunction rewriteEslintPackageJson(packageJsonPath: string): void {\n  editJsonFile<{\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n    scripts?: Record<string, string>;\n    'lint-staged'?: Record<string, string | string[]>;\n  }>(packageJsonPath, (pkg) => {\n    let changed = false;\n    if (pkg.devDependencies?.eslint) {\n      delete pkg.devDependencies.eslint;\n      changed = true;\n    }\n    if (pkg.dependencies?.eslint) {\n      delete pkg.dependencies.eslint;\n      changed = true;\n    }\n    if (pkg.scripts) {\n      const updated = rewriteEslint(JSON.stringify(pkg.scripts));\n      if (updated) {\n        pkg.scripts = JSON.parse(updated);\n        changed = true;\n      }\n    }\n    if (pkg['lint-staged']) {\n      const updated = rewriteEslint(JSON.stringify(pkg['lint-staged']));\n      if (updated) {\n        pkg['lint-staged'] = JSON.parse(updated);\n        changed = true;\n      }\n    }\n    return changed ? pkg : undefined;\n  });\n}\n\n/**\n * Rewrite tool references in lint-staged config files (JSON ones are rewritten,\n * non-JSON ones get a warning).\n */\nfunction rewriteToolLintStagedConfigFiles(\n  projectPath: string,\n  rewriteFn: (json: string) => string | null,\n  toolName: string,\n  report?: MigrationReport,\n): void {\n  for (const filename of LINT_STAGED_JSON_CONFIG_FILES) {\n    const configPath = path.join(projectPath, filename);\n    if (!fs.existsSync(configPath)) {\n      continue;\n    }\n    if (filename === '.lintstagedrc' && !isJsonFile(configPath)) {\n      warnMigration(\n        `${displayRelative(configPath)} is not JSON — please update ${toolName} references manually`,\n        report,\n      );\n      continue;\n    }\n    editJsonFile<Record<string, string | string[]>>(configPath, (config) => {\n      const updated = rewriteFn(JSON.stringify(config));\n      if (updated) {\n        return JSON.parse(updated);\n      }\n      return undefined;\n    });\n  }\n  for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) {\n    const configPath = path.join(projectPath, filename);\n    if (!fs.existsSync(configPath)) {\n      continue;\n    }\n    warnMigration(\n      `${displayRelative(configPath)} — please update ${toolName} references manually`,\n      report,\n    );\n  }\n}\n\nfunction rewriteEslintLintStagedConfigFiles(projectPath: string, report?: MigrationReport): void {\n  rewriteToolLintStagedConfigFiles(projectPath, rewriteEslint, 'eslint', report);\n}\n\nexport function detectPrettierProject(\n  projectPath: string,\n  packages?: WorkspacePackage[],\n): {\n  hasDependency: boolean;\n  configFile?: string;\n} {\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return { hasDependency: false };\n  }\n  const pkg = readJsonFile<{\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n  }>(packageJsonPath);\n  let hasDependency = !!(pkg.devDependencies?.prettier || pkg.dependencies?.prettier);\n  const configs = detectConfigs(projectPath);\n  const configFile = configs.prettierConfig;\n\n  // If root doesn't have prettier dependency, check workspace packages\n  if (!hasDependency && packages) {\n    for (const wp of packages) {\n      const pkgJsonPath = path.join(projectPath, wp.path, 'package.json');\n      if (!fs.existsSync(pkgJsonPath)) {\n        continue;\n      }\n      const wpPkg = readJsonFile<{\n        devDependencies?: Record<string, string>;\n        dependencies?: Record<string, string>;\n      }>(pkgJsonPath);\n      if (wpPkg.devDependencies?.prettier || wpPkg.dependencies?.prettier) {\n        hasDependency = true;\n        break;\n      }\n    }\n  }\n\n  return { hasDependency, configFile };\n}\n\n/**\n * Run `vp fmt --migrate=prettier` step with graceful error handling.\n * Returns true on success, false on failure.\n */\nasync function runPrettierMigrateStep(\n  vpBin: string,\n  cwd: string,\n  spinner: ReturnType<typeof getSpinner>,\n  failMessage: string,\n  manualHint: string,\n): Promise<boolean> {\n  try {\n    const result = await runCommandSilently({\n      command: vpBin,\n      args: ['fmt', '--migrate=prettier'],\n      cwd,\n      envs: process.env,\n    });\n    if (result.exitCode !== 0) {\n      spinner.stop(failMessage);\n      const stderr = result.stderr.toString().trim();\n      if (stderr) {\n        prompts.log.warn(`⚠ ${stderr}`);\n      }\n      prompts.log.info(manualHint);\n      return false;\n    }\n    return true;\n  } catch {\n    spinner.stop(failMessage);\n    prompts.log.info(manualHint);\n    return false;\n  }\n}\n\nexport async function migratePrettierToOxfmt(\n  projectPath: string,\n  interactive: boolean,\n  prettierConfigFile?: string,\n  packages?: WorkspacePackage[],\n  options?: { silent?: boolean; report?: MigrationReport },\n): Promise<boolean> {\n  const vpBin = process.env.VITE_PLUS_CLI_BIN ?? 'vp';\n  const spinner = options?.silent\n    ? {\n        start: () => {},\n        stop: () => {},\n        pause: () => {},\n        resume: () => {},\n        cancel: () => {},\n        error: () => {},\n        clear: () => {},\n        message: () => {},\n        isCancelled: false,\n      }\n    : getSpinner(interactive);\n\n  // Step 1: Generate .oxfmtrc.json from Prettier config\n  if (prettierConfigFile) {\n    let tempPrettierConfig: string | undefined;\n\n    // If config is in package.json, extract it to a temporary .prettierrc.json\n    // so that `vp fmt --migrate=prettier` can read it\n    if (prettierConfigFile === PRETTIER_PACKAGE_JSON_CONFIG) {\n      const packageJsonPath = path.join(projectPath, 'package.json');\n      const pkg = readJsonFile<{ prettier?: unknown }>(packageJsonPath);\n      if (pkg.prettier) {\n        tempPrettierConfig = path.join(projectPath, '.prettierrc.json');\n        fs.writeFileSync(tempPrettierConfig, JSON.stringify(pkg.prettier, null, 2));\n      } else {\n        // Config disappeared between detection and migration — nothing to migrate\n        return true;\n      }\n    }\n\n    try {\n      spinner.start('Migrating Prettier config to Oxfmt...');\n      const migrateOk = await runPrettierMigrateStep(\n        vpBin,\n        projectPath,\n        spinner,\n        'Prettier migration failed',\n        'You can run `vp fmt --migrate=prettier` manually later',\n      );\n      if (!migrateOk) {\n        return false;\n      }\n      spinner.stop('Prettier config migrated to .oxfmtrc.json');\n    } finally {\n      if (tempPrettierConfig) {\n        try {\n          fs.unlinkSync(tempPrettierConfig);\n        } catch {}\n      }\n    }\n  }\n\n  if (options?.report) {\n    options.report.prettierMigrated = true;\n  }\n\n  // Step 2: Delete all prettier config files at root\n  deletePrettierConfigFiles(projectPath, options?.report, options?.silent);\n\n  // Step 3: Remove prettier dependency and rewrite prettier scripts (root)\n  rewritePrettierPackageJson(path.join(projectPath, 'package.json'));\n\n  // Step 3b: Rewrite prettier scripts in workspace packages\n  if (packages) {\n    for (const pkg of packages) {\n      rewritePrettierPackageJson(path.join(projectPath, pkg.path, 'package.json'));\n    }\n  }\n\n  // Step 4: Rewrite prettier references in lint-staged config files\n  rewritePrettierLintStagedConfigFiles(projectPath, options?.report);\n\n  // Step 5: Warn about .prettierignore if it exists\n  const prettierIgnorePath = path.join(projectPath, '.prettierignore');\n  if (fs.existsSync(prettierIgnorePath)) {\n    warnMigration(\n      `${displayRelative(prettierIgnorePath)} found — Oxfmt uses .oxfmtignore. Please migrate manually.`,\n      options?.report,\n    );\n  }\n\n  return true;\n}\n\nfunction deletePrettierConfigFiles(\n  basePath: string,\n  report?: MigrationReport,\n  silent = false,\n): void {\n  // Delete detected prettier config file (like deleteEslintConfigFiles uses detectConfigs)\n  const configs = detectConfigs(basePath);\n  if (configs.prettierConfig && configs.prettierConfig !== PRETTIER_PACKAGE_JSON_CONFIG) {\n    const configPath = path.join(basePath, configs.prettierConfig);\n    if (fs.existsSync(configPath)) {\n      fs.unlinkSync(configPath);\n      if (report) {\n        report.removedConfigCount++;\n      }\n      if (!silent) {\n        prompts.log.success(`✔ Removed ${displayRelative(configPath)}`);\n      }\n    }\n  }\n  // Also clean up any stale prettier config files that detectConfigs didn't pick\n  // (prettier only uses one config, but users may have leftover files)\n  for (const file of PRETTIER_CONFIG_FILES) {\n    if (file === configs.prettierConfig) {\n      continue; // already handled above\n    }\n    const configPath = path.join(basePath, file);\n    if (fs.existsSync(configPath)) {\n      fs.unlinkSync(configPath);\n      if (report) {\n        report.removedConfigCount++;\n      }\n      if (!silent) {\n        prompts.log.success(`✔ Removed ${displayRelative(configPath)}`);\n      }\n    }\n  }\n  // Remove \"prettier\" key from package.json if present\n  editJsonFile<{ prettier?: unknown }>(path.join(basePath, 'package.json'), (pkg) => {\n    if (pkg.prettier) {\n      delete pkg.prettier;\n      return pkg;\n    }\n    return undefined;\n  });\n}\n\nfunction rewritePrettierPackageJson(packageJsonPath: string): void {\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n  editJsonFile<{\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n    scripts?: Record<string, string>;\n    'lint-staged'?: Record<string, string | string[]>;\n  }>(packageJsonPath, (pkg) => {\n    let changed = false;\n    // Remove prettier and prettier-plugin-* dependencies\n    if (pkg.devDependencies) {\n      for (const dep of Object.keys(pkg.devDependencies)) {\n        if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) {\n          delete pkg.devDependencies[dep];\n          changed = true;\n        }\n      }\n    }\n    if (pkg.dependencies) {\n      for (const dep of Object.keys(pkg.dependencies)) {\n        if (dep === 'prettier' || dep.startsWith('prettier-plugin-')) {\n          delete pkg.dependencies[dep];\n          changed = true;\n        }\n      }\n    }\n    if (pkg.scripts) {\n      const updated = rewritePrettier(JSON.stringify(pkg.scripts));\n      if (updated) {\n        pkg.scripts = JSON.parse(updated);\n        changed = true;\n      }\n    }\n    if (pkg['lint-staged']) {\n      const updated = rewritePrettier(JSON.stringify(pkg['lint-staged']));\n      if (updated) {\n        pkg['lint-staged'] = JSON.parse(updated);\n        changed = true;\n      }\n    }\n    return changed ? pkg : undefined;\n  });\n}\n\nfunction rewritePrettierLintStagedConfigFiles(projectPath: string, report?: MigrationReport): void {\n  rewriteToolLintStagedConfigFiles(projectPath, rewritePrettier, 'prettier', report);\n}\n\n/**\n * Rewrite standalone project to add vite-plus dependencies\n * @param projectPath - The path to the project\n */\nexport function rewriteStandaloneProject(\n  projectPath: string,\n  workspaceInfo: WorkspaceInfo,\n  skipStagedMigration?: boolean,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n\n  const packageManager = workspaceInfo.packageManager;\n  let extractedStagedConfig: Record<string, string | string[]> | null = null;\n  editJsonFile<{\n    overrides?: Record<string, string>;\n    resolutions?: Record<string, string>;\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n    scripts?: Record<string, string>;\n    pnpm?: {\n      overrides?: Record<string, string>;\n      // peerDependencyRules?: {\n      //   allowAny?: string[];\n      //   allowedVersions?: Record<string, string>;\n      // };\n    };\n  }>(packageJsonPath, (pkg) => {\n    if (packageManager === PackageManager.yarn) {\n      pkg.resolutions = {\n        ...pkg.resolutions,\n        ...VITE_PLUS_OVERRIDE_PACKAGES,\n      };\n    } else if (packageManager === PackageManager.npm) {\n      pkg.overrides = {\n        ...pkg.overrides,\n        ...VITE_PLUS_OVERRIDE_PACKAGES,\n      };\n    } else if (packageManager === PackageManager.pnpm) {\n      pkg.pnpm = {\n        ...pkg.pnpm,\n        overrides: {\n          ...pkg.pnpm?.overrides,\n          ...VITE_PLUS_OVERRIDE_PACKAGES,\n        },\n      };\n      // remove packages from `resolutions` field if they exist\n      // https://pnpm.io/9.x/package_json#resolutions\n      for (const key of [...Object.keys(VITE_PLUS_OVERRIDE_PACKAGES), ...REMOVE_PACKAGES]) {\n        if (pkg.resolutions?.[key]) {\n          delete pkg.resolutions[key];\n        }\n      }\n    }\n\n    extractedStagedConfig = rewritePackageJson(pkg, packageManager, false, skipStagedMigration);\n\n    // ensure vite-plus is in devDependencies\n    if (!pkg.devDependencies?.[VITE_PLUS_NAME]) {\n      pkg.devDependencies = {\n        ...pkg.devDependencies,\n        [VITE_PLUS_NAME]: VITE_PLUS_VERSION,\n      };\n    }\n    return pkg;\n  });\n\n  // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json\n  if (extractedStagedConfig) {\n    if (mergeStagedConfigToViteConfig(projectPath, extractedStagedConfig, silent, report)) {\n      removeLintStagedFromPackageJson(packageJsonPath);\n    }\n  }\n\n  if (!skipStagedMigration) {\n    rewriteLintStagedConfigFile(projectPath, report);\n  }\n  mergeViteConfigFiles(projectPath, silent, report);\n  injectLintTypeCheckDefaults(projectPath, silent, report);\n  mergeTsdownConfigFile(projectPath, silent, report);\n  // rewrite imports in all TypeScript/JavaScript files\n  rewriteAllImports(projectPath, silent, report);\n  // set package manager\n  setPackageManager(projectPath, workspaceInfo.downloadPackageManager);\n}\n\n/**\n * Rewrite monorepo to add vite-plus dependencies\n * @param workspaceInfo - The workspace info\n */\nexport function rewriteMonorepo(\n  workspaceInfo: WorkspaceInfo,\n  skipStagedMigration?: boolean,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  // rewrite root workspace\n  if (workspaceInfo.packageManager === PackageManager.pnpm) {\n    rewritePnpmWorkspaceYaml(workspaceInfo.rootDir);\n  } else if (workspaceInfo.packageManager === PackageManager.yarn) {\n    rewriteYarnrcYml(workspaceInfo.rootDir);\n  }\n  rewriteRootWorkspacePackageJson(\n    workspaceInfo.rootDir,\n    workspaceInfo.packageManager,\n    skipStagedMigration,\n  );\n\n  // rewrite packages\n  for (const pkg of workspaceInfo.packages) {\n    rewriteMonorepoProject(\n      path.join(workspaceInfo.rootDir, pkg.path),\n      workspaceInfo.packageManager,\n      skipStagedMigration,\n      silent,\n      report,\n    );\n  }\n\n  if (!skipStagedMigration) {\n    rewriteLintStagedConfigFile(workspaceInfo.rootDir, report);\n  }\n  mergeViteConfigFiles(workspaceInfo.rootDir, silent, report);\n  injectLintTypeCheckDefaults(workspaceInfo.rootDir, silent, report);\n  mergeTsdownConfigFile(workspaceInfo.rootDir, silent, report);\n  // rewrite imports in all TypeScript/JavaScript files\n  rewriteAllImports(workspaceInfo.rootDir, silent, report);\n  // set package manager\n  setPackageManager(workspaceInfo.rootDir, workspaceInfo.downloadPackageManager);\n}\n\n/**\n * Rewrite monorepo project to add vite-plus dependencies\n * @param projectPath - The path to the project\n */\nexport function rewriteMonorepoProject(\n  projectPath: string,\n  packageManager: PackageManager,\n  skipStagedMigration?: boolean,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  mergeViteConfigFiles(projectPath, silent, report);\n  mergeTsdownConfigFile(projectPath, silent, report);\n\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n\n  let extractedStagedConfig: Record<string, string | string[]> | null = null;\n  editJsonFile<{\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n    scripts?: Record<string, string>;\n  }>(packageJsonPath, (pkg) => {\n    // rewrite scripts in package.json\n    extractedStagedConfig = rewritePackageJson(pkg, packageManager, true, skipStagedMigration);\n    return pkg;\n  });\n\n  // Merge extracted staged config into vite.config.ts, then remove lint-staged from package.json\n  if (extractedStagedConfig) {\n    if (mergeStagedConfigToViteConfig(projectPath, extractedStagedConfig, silent, report)) {\n      removeLintStagedFromPackageJson(packageJsonPath);\n    }\n  }\n}\n\n/**\n * Rewrite pnpm-workspace.yaml to add vite-plus dependencies\n * @param projectPath - The path to the project\n */\nfunction rewritePnpmWorkspaceYaml(projectPath: string): void {\n  const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml');\n  if (!fs.existsSync(pnpmWorkspaceYamlPath)) {\n    fs.writeFileSync(pnpmWorkspaceYamlPath, '');\n  }\n\n  editYamlFile(pnpmWorkspaceYamlPath, (doc) => {\n    // catalog\n    rewriteCatalog(doc);\n\n    // overrides\n    for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) {\n      let version = VITE_PLUS_OVERRIDE_PACKAGES[key];\n      if (!version.startsWith('file:')) {\n        version = 'catalog:';\n      }\n      doc.setIn(['overrides', scalarString(key)], scalarString(version));\n    }\n    // remove dependency selector from vite, e.g. \"vite-plugin-svgr>vite\": \"npm:vite@7.0.12\"\n    const overrides = doc.getIn(['overrides']) as YAMLMap<Scalar<string>, Scalar<string>>;\n    for (const item of overrides.items) {\n      if (item.key.value.includes('>')) {\n        const splits = item.key.value.split('>');\n        if (splits[splits.length - 1].trim() === 'vite') {\n          overrides.delete(item.key);\n        }\n      }\n    }\n\n    // peerDependencyRules.allowAny\n    let allowAny = doc.getIn(['peerDependencyRules', 'allowAny']) as YAMLSeq<Scalar<string>>;\n    if (!allowAny) {\n      allowAny = new YAMLSeq<Scalar<string>>();\n    }\n    const existing = new Set(allowAny.items.map((n) => n.value));\n    for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) {\n      if (!existing.has(key)) {\n        allowAny.add(scalarString(key));\n      }\n    }\n    doc.setIn(['peerDependencyRules', 'allowAny'], allowAny);\n\n    // peerDependencyRules.allowedVersions\n    let allowedVersions = doc.getIn(['peerDependencyRules', 'allowedVersions']) as YAMLMap<\n      Scalar<string>,\n      Scalar<string>\n    >;\n    if (!allowedVersions) {\n      allowedVersions = new YAMLMap<Scalar<string>, Scalar<string>>();\n    }\n    for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) {\n      // - vite: '*'\n      allowedVersions.set(scalarString(key), scalarString('*'));\n    }\n    doc.setIn(['peerDependencyRules', 'allowedVersions'], allowedVersions);\n\n    // minimumReleaseAgeExclude\n    if (doc.has('minimumReleaseAge')) {\n      // add vite-plus, @voidzero-dev/*, oxlint, oxlint-tsgolint, oxfmt to minimumReleaseAgeExclude\n      const excludes = [\n        'vite-plus',\n        '@voidzero-dev/*',\n        'oxlint',\n        '@oxlint/*',\n        'oxlint-tsgolint',\n        '@oxlint-tsgolint/*',\n        'oxfmt',\n        '@oxfmt/*',\n      ];\n      let minimumReleaseAgeExclude = doc.getIn(['minimumReleaseAgeExclude']) as YAMLSeq<\n        Scalar<string>\n      >;\n      if (!minimumReleaseAgeExclude) {\n        minimumReleaseAgeExclude = new YAMLSeq();\n      }\n      const existing = new Set(minimumReleaseAgeExclude.items.map((n) => n.value));\n      for (const exclude of excludes) {\n        if (!existing.has(exclude)) {\n          minimumReleaseAgeExclude.add(scalarString(exclude));\n        }\n      }\n      doc.setIn(['minimumReleaseAgeExclude'], minimumReleaseAgeExclude);\n    }\n  });\n}\n\n/**\n * Rewrite .yarnrc.yml to add vite-plus dependencies\n * @param projectPath - The path to the project\n */\nfunction rewriteYarnrcYml(projectPath: string): void {\n  const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml');\n  if (!fs.existsSync(yarnrcYmlPath)) {\n    fs.writeFileSync(yarnrcYmlPath, '');\n  }\n\n  editYamlFile(yarnrcYmlPath, (doc) => {\n    // catalog\n    rewriteCatalog(doc);\n  });\n}\n\n/**\n * Rewrite catalog in pnpm-workspace.yaml or .yarnrc.yml\n * @param doc - The document to rewrite\n */\nfunction rewriteCatalog(doc: YamlDocument): void {\n  for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) {\n    // ERR_PNPM_CATALOG_IN_OVERRIDES  Could not resolve a catalog in the overrides: The entry for 'vite' in catalog 'default' declares a dependency using the 'file' protocol\n    // ignore setting catalog if value starts with 'file:'\n    if (value.startsWith('file:')) {\n      continue;\n    }\n    doc.setIn(['catalog', key], scalarString(value));\n  }\n  if (!VITE_PLUS_VERSION.startsWith('file:')) {\n    doc.setIn(['catalog', VITE_PLUS_NAME], scalarString(VITE_PLUS_VERSION));\n  }\n  for (const name of REMOVE_PACKAGES) {\n    const path = ['catalog', name];\n    if (doc.hasIn(path)) {\n      doc.deleteIn(path);\n    }\n  }\n\n  // TODO: rewrite `catalogs` when OVERRIDE_PACKAGES exists in catalog\n}\n\n/**\n * Rewrite root workspace package.json to add vite-plus dependencies\n * @param projectPath - The path to the project\n */\nfunction rewriteRootWorkspacePackageJson(\n  projectPath: string,\n  packageManager: PackageManager,\n  skipStagedMigration?: boolean,\n): void {\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n\n  editJsonFile<{\n    resolutions?: Record<string, string>;\n    overrides?: Record<string, string>;\n    devDependencies?: Record<string, string>;\n    pnpm?: {\n      overrides?: Record<string, string>;\n    };\n  }>(packageJsonPath, (pkg) => {\n    if (packageManager === PackageManager.yarn) {\n      pkg.resolutions = {\n        ...pkg.resolutions,\n        // FIXME: yarn don't support catalog on resolutions\n        // https://github.com/yarnpkg/berry/issues/6979\n        ...VITE_PLUS_OVERRIDE_PACKAGES,\n      };\n    } else if (packageManager === PackageManager.npm) {\n      pkg.overrides = {\n        ...pkg.overrides,\n        ...VITE_PLUS_OVERRIDE_PACKAGES,\n      };\n    } else if (packageManager === PackageManager.pnpm) {\n      if (isForceOverrideMode()) {\n        // In force-override mode, keep overrides in package.json pnpm.overrides\n        // because pnpm ignores pnpm-workspace.yaml overrides when pnpm.overrides\n        // exists in package.json (even with unrelated entries like rollup).\n        pkg.pnpm = {\n          ...pkg.pnpm,\n          overrides: {\n            ...pkg.pnpm?.overrides,\n            ...VITE_PLUS_OVERRIDE_PACKAGES,\n            [VITE_PLUS_NAME]: VITE_PLUS_VERSION,\n          },\n        };\n      } else {\n        // pnpm use overrides field at pnpm-workspace.yaml\n        // so we don't need to set overrides field at package.json\n        // remove packages from `resolutions` field and `pnpm.overrides` field if they exist\n        // https://pnpm.io/9.x/package_json#resolutions\n        for (const key of [...Object.keys(VITE_PLUS_OVERRIDE_PACKAGES), ...REMOVE_PACKAGES]) {\n          if (pkg.pnpm?.overrides?.[key]) {\n            delete pkg.pnpm.overrides[key];\n          }\n          if (pkg.resolutions?.[key]) {\n            delete pkg.resolutions[key];\n          }\n        }\n      }\n      // remove dependency selector from vite, e.g. \"vite-plugin-svgr>vite\": \"npm:vite@7.0.12\"\n      for (const key in pkg.pnpm?.overrides) {\n        if (key.includes('>')) {\n          const splits = key.split('>');\n          if (splits[splits.length - 1].trim() === 'vite') {\n            delete pkg.pnpm.overrides[key];\n          }\n        }\n      }\n    }\n\n    // ensure vite-plus is in devDependencies\n    if (!pkg.devDependencies?.[VITE_PLUS_NAME]) {\n      pkg.devDependencies = {\n        ...pkg.devDependencies,\n        [VITE_PLUS_NAME]:\n          packageManager === PackageManager.npm || VITE_PLUS_VERSION.startsWith('file:')\n            ? VITE_PLUS_VERSION\n            : 'catalog:',\n      };\n    }\n    return pkg;\n  });\n\n  // rewrite package.json\n  rewriteMonorepoProject(projectPath, packageManager, skipStagedMigration);\n}\n\nconst RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml');\nconst PREPARE_RULES_YAML_PATH = path.join(rulesDir, 'vite-prepare.yml');\n\n// Cache YAML content to avoid repeated disk reads (called once per package in monorepos)\nlet cachedRulesYaml: string | undefined;\nlet cachedRulesYamlNoLintStaged: string | undefined;\nlet cachedPrepareRulesYaml: string | undefined;\nfunction readRulesYaml(): string {\n  cachedRulesYaml ??= fs.readFileSync(RULES_YAML_PATH, 'utf8');\n  return cachedRulesYaml;\n}\nfunction getScriptRulesYaml(skipStagedMigration?: boolean): string {\n  const yaml = readRulesYaml();\n  if (!skipStagedMigration) {\n    return yaml;\n  }\n  cachedRulesYamlNoLintStaged ??= yaml\n    .split('\\n\\n\\n')\n    .filter((block) => !block.includes('id: replace-lint-staged'))\n    .join('\\n\\n\\n');\n  return cachedRulesYamlNoLintStaged;\n}\nfunction readPrepareRulesYaml(): string {\n  cachedPrepareRulesYaml ??= fs.readFileSync(PREPARE_RULES_YAML_PATH, 'utf8');\n  return cachedPrepareRulesYaml;\n}\n\nexport function rewritePackageJson(\n  pkg: {\n    scripts?: Record<string, string>;\n    'lint-staged'?: Record<string, string | string[]>;\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n  },\n  packageManager: PackageManager,\n  isMonorepo?: boolean,\n  skipStagedMigration?: boolean,\n): Record<string, string | string[]> | null {\n  if (pkg.scripts) {\n    const updated = rewriteScripts(\n      JSON.stringify(pkg.scripts),\n      getScriptRulesYaml(skipStagedMigration),\n    );\n    if (updated) {\n      pkg.scripts = JSON.parse(updated);\n    }\n  }\n  // Extract staged config from package.json (lint-staged) → will be merged into vite.config.ts.\n  // The lint-staged key is NOT deleted here — it's removed by the caller only after\n  // the merge into vite.config.ts succeeds, to avoid losing config on merge failure.\n  let extractedStagedConfig: Record<string, string | string[]> | null = null;\n  if (!skipStagedMigration && pkg['lint-staged']) {\n    const config = pkg['lint-staged'];\n    const updated = rewriteScripts(JSON.stringify(config), readRulesYaml());\n    extractedStagedConfig = updated ? JSON.parse(updated) : config;\n  }\n  const supportCatalog = isMonorepo && packageManager !== PackageManager.npm;\n  let needVitePlus = false;\n  for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) {\n    const value = supportCatalog && !version.startsWith('file:') ? 'catalog:' : version;\n    if (pkg.devDependencies?.[key]) {\n      pkg.devDependencies[key] = value;\n      needVitePlus = true;\n    }\n    if (pkg.dependencies?.[key]) {\n      pkg.dependencies[key] = value;\n      needVitePlus = true;\n    }\n  }\n  // remove packages that are replaced with vite-plus\n  for (const name of REMOVE_PACKAGES) {\n    if (pkg.devDependencies?.[name]) {\n      delete pkg.devDependencies[name];\n      needVitePlus = true;\n    }\n    if (pkg.dependencies?.[name]) {\n      delete pkg.dependencies[name];\n      needVitePlus = true;\n    }\n  }\n  if (needVitePlus) {\n    // add vite-plus to devDependencies\n    const version =\n      supportCatalog && !VITE_PLUS_VERSION.startsWith('file:') ? 'catalog:' : VITE_PLUS_VERSION;\n    pkg.devDependencies = {\n      ...pkg.devDependencies,\n      [VITE_PLUS_NAME]: version,\n    };\n  }\n  return extractedStagedConfig;\n}\n\n// Remove the \"lint-staged\" key from package.json after config has been\n// successfully merged into vite.config.ts.\nfunction removeLintStagedFromPackageJson(packageJsonPath: string): void {\n  editJsonFile<{ 'lint-staged'?: Record<string, string | string[]> }>(packageJsonPath, (pkg) => {\n    if (pkg['lint-staged']) {\n      delete pkg['lint-staged'];\n      return pkg;\n    }\n    return undefined;\n  });\n}\n\n// Migrate standalone lint-staged config files into staged in vite.config.ts.\n// JSON-parseable files are inlined automatically; non-JSON files get a warning.\nfunction rewriteLintStagedConfigFile(projectPath: string, report?: MigrationReport): void {\n  let hasUnsupported = false;\n\n  for (const filename of LINT_STAGED_JSON_CONFIG_FILES) {\n    const configPath = path.join(projectPath, filename);\n    if (!fs.existsSync(configPath)) {\n      continue;\n    }\n    if (filename === '.lintstagedrc' && !isJsonFile(configPath)) {\n      warnMigration(\n        `${displayRelative(configPath)} is not JSON format — please migrate to \"staged\" in vite.config.ts manually`,\n        report,\n      );\n      hasUnsupported = true;\n      continue;\n    }\n    // Merge the JSON config into vite.config.ts as \"staged\" and delete the file.\n    // Skip if staged already exists in vite.config.ts (already migrated by rewritePackageJson).\n    if (!hasStagedConfigInViteConfig(projectPath)) {\n      const config = readJsonFile(configPath);\n      const updated = rewriteScripts(JSON.stringify(config), readRulesYaml());\n      const finalConfig = updated ? JSON.parse(updated) : config;\n      if (!mergeStagedConfigToViteConfig(projectPath, finalConfig, true, report)) {\n        // Merge failed — preserve the original config file so the user doesn't lose their rules\n        continue;\n      }\n      fs.unlinkSync(configPath);\n      if (report) {\n        report.inlinedLintStagedConfigCount++;\n      }\n    } else {\n      warnMigration(\n        `${displayRelative(configPath)} found but \"staged\" already exists in vite.config.ts — please merge manually`,\n        report,\n      );\n    }\n  }\n  // Non-JSON standalone files — warn\n  for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) {\n    const configPath = path.join(projectPath, filename);\n    if (!fs.existsSync(configPath)) {\n      continue;\n    }\n    warnMigration(\n      `${displayRelative(configPath)} — please migrate to \"staged\" in vite.config.ts manually`,\n      report,\n    );\n    hasUnsupported = true;\n  }\n  if (hasUnsupported) {\n    infoMigration(\n      'Only \"staged\" in vite.config.ts is supported. See https://viteplus.dev/guide/migrate#lint-staged',\n      report,\n    );\n  }\n}\n\n/**\n * Ensure vite.config.ts exists, create it if not\n * @returns The vite config filename\n */\nfunction ensureViteConfig(\n  projectPath: string,\n  configs: ConfigFiles,\n  silent = false,\n  report?: MigrationReport,\n): string {\n  if (!configs.viteConfig) {\n    configs.viteConfig = 'vite.config.ts';\n    const viteConfigPath = path.join(projectPath, 'vite.config.ts');\n    fs.writeFileSync(\n      viteConfigPath,\n      `import { defineConfig } from '${VITE_PLUS_NAME}';\n\nexport default defineConfig({});\n`,\n    );\n    if (report) {\n      report.createdViteConfigCount++;\n    }\n    if (!silent) {\n      prompts.log.success(`✔ Created vite.config.ts in ${displayRelative(viteConfigPath)}`);\n    }\n  }\n  return configs.viteConfig;\n}\n\n/**\n * Merge tsdown.config.* into vite.config.ts\n * - For JSON files: merge content directly into `pack` field and delete the JSON file\n * - For TS/JS files: import the config file\n */\nfunction mergeTsdownConfigFile(\n  projectPath: string,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  const configs = detectConfigs(projectPath);\n  if (!configs.tsdownConfig) {\n    return;\n  }\n  const viteConfig = ensureViteConfig(projectPath, configs, silent, report);\n\n  const fullViteConfigPath = path.join(projectPath, viteConfig);\n  const fullTsdownConfigPath = path.join(projectPath, configs.tsdownConfig);\n\n  // For JSON files, merge content directly and delete the file\n  if (configs.tsdownConfig.endsWith('.json')) {\n    mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.tsdownConfig, 'pack', silent, report);\n    return;\n  }\n\n  // For TS/JS files, import the config file\n  const tsdownRelativePath = `./${configs.tsdownConfig}`;\n  const result = mergeTsdownConfig(fullViteConfigPath, tsdownRelativePath);\n  if (result.updated) {\n    fs.writeFileSync(fullViteConfigPath, result.content);\n    if (report) {\n      report.tsdownImportCount++;\n    }\n    if (!silent) {\n      prompts.log.success(\n        `✔ Added import for ${displayRelative(fullTsdownConfigPath)} in ${displayRelative(fullViteConfigPath)}`,\n      );\n    }\n  }\n  // Show documentation link for manual merging since we only added the import\n  infoMigration(\n    `Please manually merge ${displayRelative(fullTsdownConfigPath)} into ${displayRelative(fullViteConfigPath)}, see https://viteplus.dev/guide/migrate#tsdown`,\n    report,\n  );\n}\n\n/**\n * Merge oxlint and oxfmt config into vite.config.ts\n */\nexport function mergeViteConfigFiles(\n  projectPath: string,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  const configs = detectConfigs(projectPath);\n  if (!configs.oxfmtConfig && !configs.oxlintConfig) {\n    return;\n  }\n  const viteConfig = ensureViteConfig(projectPath, configs, silent, report);\n  if (configs.oxlintConfig) {\n    // Inject options.typeAware and options.typeCheck defaults before merging\n    const fullOxlintPath = path.join(projectPath, configs.oxlintConfig);\n    const oxlintJson = JSON.parse(fs.readFileSync(fullOxlintPath, 'utf8'));\n    if (!oxlintJson.options) {\n      oxlintJson.options = {};\n    }\n    // Skip typeAware/typeCheck when tsconfig.json has baseUrl (unsupported by tsgolint)\n    if (!hasBaseUrlInTsconfig(projectPath)) {\n      if (oxlintJson.options.typeAware === undefined) {\n        oxlintJson.options.typeAware = true;\n      }\n      if (oxlintJson.options.typeCheck === undefined) {\n        oxlintJson.options.typeCheck = true;\n      }\n    } else {\n      warnMigration(BASEURL_TSCONFIG_WARNING, report);\n    }\n    fs.writeFileSync(fullOxlintPath, JSON.stringify(oxlintJson, null, 2));\n    // merge oxlint config into vite.config.ts\n    mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxlintConfig, 'lint', silent, report);\n  }\n  if (configs.oxfmtConfig) {\n    // merge oxfmt config into vite.config.ts\n    mergeAndRemoveJsonConfig(projectPath, viteConfig, configs.oxfmtConfig, 'fmt', silent, report);\n  }\n}\n\n/**\n * Inject typeAware and typeCheck defaults into vite.config.ts lint config.\n * Called after mergeViteConfigFiles() to handle the case where no .oxlintrc.json exists\n * (e.g., newly created projects from create-vite templates).\n */\nexport function injectLintTypeCheckDefaults(\n  projectPath: string,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  if (hasBaseUrlInTsconfig(projectPath)) {\n    return;\n  }\n  injectConfigDefaults(\n    projectPath,\n    'lint',\n    '.vite-plus-lint-init.oxlintrc.json',\n    JSON.stringify({ options: { typeAware: true, typeCheck: true } }),\n    silent,\n    report,\n  );\n}\n\nfunction injectConfigDefaults(\n  projectPath: string,\n  configKey: string,\n  tempFileName: string,\n  tempFileContent: string,\n  silent: boolean,\n  report?: MigrationReport,\n): void {\n  const configs = detectConfigs(projectPath);\n  if (configs.viteConfig) {\n    const content = fs.readFileSync(path.join(projectPath, configs.viteConfig), 'utf8');\n    if (new RegExp(`\\\\b${configKey}\\\\s*:`).test(content)) {\n      return;\n    }\n  }\n\n  const viteConfig = ensureViteConfig(projectPath, configs, silent, report);\n  const tempConfigPath = path.join(projectPath, tempFileName);\n  fs.writeFileSync(tempConfigPath, tempFileContent);\n  const fullViteConfigPath = path.join(projectPath, viteConfig);\n  let result;\n  try {\n    result = mergeJsonConfig(fullViteConfigPath, tempConfigPath, configKey);\n  } finally {\n    fs.rmSync(tempConfigPath, { force: true });\n  }\n  if (result.updated) {\n    fs.writeFileSync(fullViteConfigPath, result.content);\n  }\n}\n\nfunction mergeAndRemoveJsonConfig(\n  projectPath: string,\n  viteConfigPath: string,\n  jsonConfigPath: string,\n  configKey: string,\n  silent = false,\n  report?: MigrationReport,\n): void {\n  const fullViteConfigPath = path.join(projectPath, viteConfigPath);\n  const fullJsonConfigPath = path.join(projectPath, jsonConfigPath);\n  const result = mergeJsonConfig(fullViteConfigPath, fullJsonConfigPath, configKey);\n  if (result.updated) {\n    fs.writeFileSync(fullViteConfigPath, result.content);\n    fs.unlinkSync(fullJsonConfigPath);\n    if (report) {\n      report.mergedConfigCount++;\n    }\n    if (!silent) {\n      prompts.log.success(\n        `✔ Merged ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`,\n      );\n    }\n  } else {\n    warnMigration(\n      `Failed to merge ${displayRelative(fullJsonConfigPath)} into ${displayRelative(fullViteConfigPath)}`,\n      report,\n    );\n    infoMigration(\n      'Please complete the merge manually and follow the instructions in the documentation: https://viteplus.dev/config/',\n      report,\n    );\n  }\n}\n\n/**\n * Merge a staged config object into vite.config.ts as `staged: { ... }`.\n * Writes the config to a temp JSON file, calls mergeJsonConfig NAPI, then cleans up.\n */\nexport function mergeStagedConfigToViteConfig(\n  projectPath: string,\n  stagedConfig: Record<string, string | string[]>,\n  silent = false,\n  report?: MigrationReport,\n): boolean {\n  const configs = detectConfigs(projectPath);\n  const viteConfig = ensureViteConfig(projectPath, configs, silent, report);\n  const fullViteConfigPath = path.join(projectPath, viteConfig);\n\n  // Write staged config to a temp JSON file for mergeJsonConfig NAPI\n  const tempJsonPath = path.join(projectPath, '.staged-config-temp.json');\n  fs.writeFileSync(tempJsonPath, JSON.stringify(stagedConfig, null, 2));\n\n  let result;\n  try {\n    result = mergeJsonConfig(fullViteConfigPath, tempJsonPath, 'staged');\n  } finally {\n    fs.unlinkSync(tempJsonPath);\n  }\n\n  if (result.updated) {\n    fs.writeFileSync(fullViteConfigPath, result.content);\n    if (report) {\n      report.mergedStagedConfigCount++;\n    }\n    if (!silent) {\n      prompts.log.success(`✔ Merged staged config into ${displayRelative(fullViteConfigPath)}`);\n    }\n    return true;\n  } else {\n    warnMigration(\n      `Failed to merge staged config into ${displayRelative(fullViteConfigPath)}`,\n      report,\n    );\n    infoMigration(\n      `Please add staged config to ${displayRelative(fullViteConfigPath)} manually, see https://viteplus.dev/guide/migrate#lint-staged`,\n      report,\n    );\n    return false;\n  }\n}\n\n/**\n * Check if vite.config.ts already has a `staged` config key.\n */\nexport function hasStagedConfigInViteConfig(projectPath: string): boolean {\n  const configs = detectConfigs(projectPath);\n  if (!configs.viteConfig) {\n    return false;\n  }\n  const viteConfigPath = path.join(projectPath, configs.viteConfig);\n  const content = fs.readFileSync(viteConfigPath, 'utf8');\n  return /\\bstaged\\s*:/.test(content);\n}\n\n/**\n * Rewrite imports in all TypeScript/JavaScript files under a directory\n * This rewrites vite/vitest imports to @voidzero-dev/vite-plus\n * @param projectPath - The root directory to search for files\n */\nfunction rewriteAllImports(projectPath: string, silent = false, report?: MigrationReport): void {\n  const result = rewriteImportsInDirectory(projectPath);\n  const modified = result.modifiedFiles.length;\n  const errors = result.errors.length;\n\n  if (report) {\n    report.rewrittenImportFileCount += modified;\n    report.rewrittenImportErrors.push(\n      ...result.errors.map((error) => ({\n        path: displayRelative(error.path),\n        message: error.message,\n      })),\n    );\n  }\n\n  if (!silent && modified > 0) {\n    prompts.log.success(`Rewrote imports in ${modified === 1 ? 'one file' : `${modified} files`}`);\n    prompts.log.info(result.modifiedFiles.map((file) => `  ${displayRelative(file)}`).join('\\n'));\n  }\n\n  if (errors > 0) {\n    if (report) {\n      warnMigration(\n        `${errors === 1 ? 'one file had an error' : `${errors} files had errors`} while rewriting imports`,\n        report,\n      );\n    } else {\n      prompts.log.warn(\n        `⚠ ${errors === 1 ? 'one file had an error' : `${errors} files had errors`}:`,\n      );\n      for (const error of result.errors) {\n        prompts.log.error(`  ${displayRelative(error.path)}: ${error.message}`);\n      }\n    }\n  }\n}\n\n/**\n * Check if the project has an unsupported husky version (<9.0.0).\n * Uses `semver.coerce` to handle ranges like `^8.0.0` → `8.0.0`.\n * Accepts pre-loaded deps to avoid re-reading package.json when called\n * from contexts that already parsed it.\n */\nfunction checkUnsupportedHuskyVersion(\n  deps: Record<string, string> | undefined,\n  prodDeps: Record<string, string> | undefined,\n): boolean {\n  const huskyVersion = deps?.husky ?? prodDeps?.husky;\n  if (!huskyVersion) {\n    return false;\n  }\n  return semver.satisfies(semver.coerce(huskyVersion) ?? '0.0.0', '<9.0.0');\n}\n\nconst OTHER_HOOK_TOOLS = ['simple-git-hooks', 'lefthook', 'yorkie'] as const;\n\n// Packages replaced by vite-plus built-in commands and should be removed from devDependencies\nconst REPLACED_HOOK_PACKAGES = ['husky', 'lint-staged'] as const;\n\nfunction removeReplacedHookPackages(packageJsonPath: string): void {\n  editJsonFile<{\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n  }>(packageJsonPath, (pkg) => {\n    for (const name of REPLACED_HOOK_PACKAGES) {\n      if (pkg.devDependencies?.[name]) {\n        delete pkg.devDependencies[name];\n      }\n      if (pkg.dependencies?.[name]) {\n        delete pkg.dependencies[name];\n      }\n    }\n    return pkg;\n  });\n}\n\n/**\n * Walk up from `startPath` looking for `.git` (directory or file — submodules\n * use a `.git` file).  Returns the directory that contains `.git`, or `null`.\n */\nfunction findGitRoot(startPath: string): string | null {\n  let dir = startPath;\n  while (true) {\n    if (fs.existsSync(path.join(dir, '.git'))) {\n      return dir;\n    }\n    const parent = path.dirname(dir);\n    if (parent === dir) {\n      return null;\n    }\n    dir = parent;\n  }\n}\n\n/**\n * Normalize \"husky install [dir]\" → \"husky [dir]\" so downstream regex\n * and ast-grep rules can match a single pattern.\n */\nfunction collapseHuskyInstall(script: string): string {\n  return script.replace('husky install ', 'husky ').replace('husky install', 'husky');\n}\n\n/**\n * High-level helper: detect old hooks dir, set up git hooks, and rewrite\n * the prepare script.  Returns true if hooks were successfully installed.\n */\nexport function installGitHooks(\n  projectPath: string,\n  silent = false,\n  report?: MigrationReport,\n): boolean {\n  const oldHooksDir = getOldHooksDir(projectPath);\n  if (setupGitHooks(projectPath, oldHooksDir, silent, report)) {\n    rewritePrepareScript(projectPath);\n    return true;\n  }\n  return false;\n}\n\n/**\n * Read-only probe: extract the old husky hooks directory from `scripts.prepare`\n * without modifying package.json. Returns undefined when no husky reference is found.\n */\nexport function getOldHooksDir(rootDir: string): string | undefined {\n  const packageJsonPath = path.join(rootDir, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n  const pkg = readJsonFile<{ scripts?: { prepare?: string } }>(packageJsonPath);\n  if (!pkg.scripts?.prepare) {\n    return;\n  }\n  const prepare = collapseHuskyInstall(pkg.scripts.prepare);\n  const match = prepare.match(/\\bhusky(?:\\s+([\\w./-]+))?/);\n  if (!match) {\n    return;\n  }\n  return match[1] ?? '.husky';\n}\n\n/**\n * Pre-flight check: verify that git hooks can be set up for this project.\n * Returns `null` if hooks setup can proceed, or a warning reason string\n * explaining why hooks setup should be skipped.\n *\n * These checks are deterministic and read-only — they do not modify\n * the project in any way, making them safe to call before migration.\n */\nexport function preflightGitHooksSetup(projectPath: string): string | null {\n  const gitRoot = findGitRoot(projectPath);\n  if (gitRoot && path.resolve(projectPath) !== path.resolve(gitRoot)) {\n    return 'Subdirectory project detected — skipping git hooks setup. Configure hooks at the repository root.';\n  }\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return null; // silently skip\n  }\n  const pkgContent = readJsonFile(packageJsonPath);\n  const deps = pkgContent.devDependencies as Record<string, string> | undefined;\n  const prodDeps = pkgContent.dependencies as Record<string, string> | undefined;\n  for (const tool of OTHER_HOOK_TOOLS) {\n    if (deps?.[tool] || prodDeps?.[tool] || pkgContent[tool]) {\n      return `Detected ${tool} — skipping git hooks setup. Please configure git hooks manually.`;\n    }\n  }\n  if (checkUnsupportedHuskyVersion(deps, prodDeps)) {\n    return 'Detected husky <9.0.0 — please upgrade to husky v9+ first, then re-run migration.';\n  }\n  if (hasUnsupportedLintStagedConfig(projectPath)) {\n    return 'Unsupported lint-staged config format — skipping git hooks setup. Please configure git hooks manually.';\n  }\n  return null;\n}\n\n/**\n * Set up git hooks with husky + lint-staged via vp commands.\n * Skips if another hook tool is detected (warns user).\n * Returns true if hooks were successfully set up, false if skipped.\n */\nexport function setupGitHooks(\n  projectPath: string,\n  oldHooksDir?: string,\n  silent = false,\n  report?: MigrationReport,\n): boolean {\n  const reason = preflightGitHooksSetup(projectPath);\n  if (reason) {\n    warnMigration(reason, report);\n    return false;\n  }\n\n  const packageJsonPath = path.join(projectPath, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return false;\n  }\n\n  const gitRoot = findGitRoot(projectPath);\n\n  // Custom husky dirs (e.g. .config/husky) stay unchanged;\n  // only the default .husky dir gets migrated to .vite-hooks.\n  const isCustomDir = oldHooksDir != null && oldHooksDir !== '.husky';\n  const hooksDir = isCustomDir ? oldHooksDir : '.vite-hooks';\n\n  editJsonFile<{\n    scripts?: Record<string, string>;\n    devDependencies?: Record<string, string>;\n    dependencies?: Record<string, string>;\n  }>(packageJsonPath, (pkg) => {\n    // Ensure vp config is present for projects that didn't have husky.\n    // Skip when prepare contains \"husky\" — rewritePrepareScript (called after\n    // setupGitHooks succeeds) will transform husky → vp config.\n    if (!pkg.scripts) {\n      pkg.scripts = {};\n    }\n    if (!pkg.scripts.prepare) {\n      pkg.scripts.prepare = 'vp config';\n    } else if (\n      !pkg.scripts.prepare.includes('vp config') &&\n      !/\\bhusky\\b/.test(pkg.scripts.prepare)\n    ) {\n      pkg.scripts.prepare = `vp config && ${pkg.scripts.prepare}`;\n    }\n\n    return pkg;\n  });\n\n  // Add staged config to vite.config.ts if not present\n  let stagedMerged = hasStagedConfigInViteConfig(projectPath);\n  const hasStandaloneConfig = hasStandaloneLintStagedConfig(projectPath);\n  if (!stagedMerged && !hasStandaloneConfig) {\n    // Use lint-staged config from package.json if available, otherwise use default\n    const pkgData = readJsonFile<{ 'lint-staged'?: Record<string, string | string[]> }>(\n      packageJsonPath,\n    );\n    const stagedConfig = pkgData?.['lint-staged'] ?? DEFAULT_STAGED_CONFIG;\n    const updated = rewriteScripts(JSON.stringify(stagedConfig), readRulesYaml());\n    const finalConfig: Record<string, string | string[]> = updated\n      ? JSON.parse(updated)\n      : stagedConfig;\n    stagedMerged = mergeStagedConfigToViteConfig(projectPath, finalConfig, silent, report);\n  }\n\n  // Only remove lint-staged key from package.json after staged config is\n  // confirmed in vite.config.ts — prevents losing config on merge failure\n  if (stagedMerged) {\n    removeLintStagedFromPackageJson(packageJsonPath);\n  }\n\n  // Copy default .husky/ hooks to .vite-hooks/ before creating pre-commit hook.\n  // Custom dirs (e.g. .config/husky) are kept in-place — no copy needed.\n  if (oldHooksDir && !isCustomDir) {\n    const oldDir = path.join(projectPath, oldHooksDir);\n    if (fs.existsSync(oldDir)) {\n      const targetDir = path.join(projectPath, hooksDir);\n      fs.mkdirSync(targetDir, { recursive: true });\n      for (const entry of fs.readdirSync(oldDir, { withFileTypes: true })) {\n        if (entry.isDirectory() || entry.name.startsWith('.')) {\n          continue;\n        }\n        const src = path.join(oldDir, entry.name);\n        const dest = path.join(targetDir, entry.name);\n        fs.copyFileSync(src, dest);\n        fs.chmodSync(dest, 0o755);\n      }\n      // Remove old .husky/ directory after copying hooks to .vite-hooks/\n      fs.rmSync(oldDir, { recursive: true, force: true });\n    }\n  }\n\n  // Only create pre-commit hook if staged config was merged into vite.config.ts.\n  // Standalone lint-staged config files are NOT sufficient — `vp staged` only\n  // reads from vite.config.ts, so a hook without merged config would fail.\n  if (stagedMerged) {\n    createPreCommitHook(projectPath, hooksDir);\n  }\n\n  // vp config requires a git workspace — skip if no .git found\n  if (!gitRoot) {\n    removeReplacedHookPackages(packageJsonPath);\n    return true;\n  }\n\n  // Clear husky's core.hooksPath so vp config can set the new one.\n  // Only clear if it matches the old husky directory — preserve genuinely custom paths.\n  if (oldHooksDir) {\n    const checkResult = spawn.sync('git', ['config', '--local', 'core.hooksPath'], {\n      cwd: projectPath,\n      stdio: 'pipe',\n    });\n    const existingPath = checkResult.status === 0 ? checkResult.stdout?.toString().trim() : '';\n    if (existingPath === `${oldHooksDir}/_` || existingPath === oldHooksDir) {\n      spawn.sync('git', ['config', '--local', '--unset', 'core.hooksPath'], {\n        cwd: projectPath,\n        stdio: 'pipe',\n      });\n    }\n  }\n\n  const vpBin = process.env.VITE_PLUS_CLI_BIN ?? 'vp';\n\n  // Install git hooks via vp config (--hooks-only to skip agent setup, handled by migration)\n  const configArgs = isCustomDir\n    ? ['config', '--hooks-only', '--hooks-dir', hooksDir]\n    : ['config', '--hooks-only'];\n  const configResult = spawn.sync(vpBin, configArgs, {\n    cwd: projectPath,\n    stdio: 'pipe',\n  });\n  if (configResult.status === 0) {\n    // vp config outputs skip/info messages to stdout via log().\n    // An empty message means hooks were installed successfully;\n    // any non-empty output indicates a skip (HUSKY=0, hooksPath\n    // already set, .git not found, etc.).\n    const stdout = configResult.stdout?.toString().trim() ?? '';\n    if (stdout) {\n      warnMigration(`Git hooks not configured — ${stdout}`, report);\n      return false;\n    }\n    removeReplacedHookPackages(packageJsonPath);\n    if (report) {\n      report.gitHooksConfigured = true;\n    }\n    if (!silent) {\n      prompts.log.success('✔ Git hooks configured');\n    }\n    return true;\n  }\n  warnMigration('Failed to install git hooks', report);\n  return false;\n}\n\n/**\n * Check if a standalone lint-staged config file exists\n */\nfunction hasStandaloneLintStagedConfig(projectPath: string): boolean {\n  return LINT_STAGED_ALL_CONFIG_FILES.some((file) => fs.existsSync(path.join(projectPath, file)));\n}\n\n/**\n * Check if a standalone lint-staged config exists in a format that can't be\n * auto-migrated to \"staged\" in vite.config.ts (non-JSON files like .yaml,\n * .mjs, .cjs, .js, or a non-JSON .lintstagedrc).\n */\nfunction hasUnsupportedLintStagedConfig(projectPath: string): boolean {\n  for (const filename of LINT_STAGED_OTHER_CONFIG_FILES) {\n    if (fs.existsSync(path.join(projectPath, filename))) {\n      return true;\n    }\n  }\n  const lintstagedrcPath = path.join(projectPath, '.lintstagedrc');\n  if (fs.existsSync(lintstagedrcPath) && !isJsonFile(lintstagedrcPath)) {\n    return true;\n  }\n  return false;\n}\n\n/**\n * Create pre-commit hook file in the hooks directory.\n */\n// Lint-staged invocation patterns — replaced in-place with `vp staged`.\n// The optional prefix group captures env var assignments like `NODE_OPTIONS=... `.\n// We still detect old lint-staged patterns to migrate existing hooks.\nconst STALE_LINT_STAGED_PATTERNS = [\n  /^((?:[A-Z_][A-Z0-9_]*(?:=\\S*)?\\s+)*)(pnpm|pnpm exec|npx|yarn|yarn run|npm exec|npm run|bunx|bun run|bun x)\\s+lint-staged\\b/,\n  /^((?:[A-Z_][A-Z0-9_]*(?:=\\S*)?\\s+)*)lint-staged\\b/,\n];\n\nconst DEFAULT_STAGED_CONFIG: Record<string, string> = { '*': 'vp check --fix' };\n\n/**\n * Ensure the pre-commit hook exists with `vp staged`, and that\n * vite.config.ts contains a `staged` block (using the default config\n * if none is present). Called by `vp config` after hook installation.\n */\nexport function ensurePreCommitHook(projectPath: string, dir = '.vite-hooks'): void {\n  if (!hasStagedConfigInViteConfig(projectPath)) {\n    mergeStagedConfigToViteConfig(projectPath, DEFAULT_STAGED_CONFIG, true);\n  }\n  createPreCommitHook(projectPath, dir);\n}\n\nexport function createPreCommitHook(projectPath: string, dir = '.vite-hooks'): void {\n  const huskyDir = path.join(projectPath, dir);\n  fs.mkdirSync(huskyDir, { recursive: true });\n  const hookPath = path.join(huskyDir, 'pre-commit');\n  if (fs.existsSync(hookPath)) {\n    const existing = fs.readFileSync(hookPath, 'utf8');\n    if (existing.includes('vp staged')) {\n      return; // already has vp staged\n    }\n    // Replace old lint-staged invocations in-place, preserve everything else\n    const lines = existing.split('\\n');\n    let replaced = false;\n    const result: string[] = [];\n    for (const line of lines) {\n      const trimmed = line.trim();\n      if (!replaced) {\n        let matched = false;\n        for (const pattern of STALE_LINT_STAGED_PATTERNS) {\n          const match = pattern.exec(trimmed);\n          if (match) {\n            // Preserve env var prefix (capture group 1) and flags/chained commands after lint-staged\n            const envPrefix = match[1]?.trim() ?? '';\n            const rest = trimmed.slice(match[0].length).trim();\n            const parts = [envPrefix, 'vp staged', rest].filter(Boolean);\n            result.push(parts.join(' '));\n            replaced = true;\n            matched = true;\n            break;\n          }\n        }\n        if (matched) {\n          continue;\n        }\n      }\n      result.push(line);\n    }\n    if (!replaced) {\n      // No lint-staged line found — append after existing content\n      fs.writeFileSync(hookPath, `${result.join('\\n').trimEnd()}\\nvp staged\\n`);\n    } else {\n      fs.writeFileSync(hookPath, result.join('\\n'));\n    }\n  } else {\n    fs.writeFileSync(hookPath, 'vp staged\\n');\n    fs.chmodSync(hookPath, 0o755);\n  }\n}\n\n/**\n * Rewrite only `scripts.prepare` in the root package.json using vite-prepare.yml rules.\n * Collapses \"husky install\" → \"husky\" before applying ast-grep so that the\n * replace-husky rule produces \"vp config\" with any directory argument preserved.\n * Returns the old husky hooks dir (if any) for migration to .vite-hooks.\n * Called only when hooks are being set up (not with --no-hooks).\n */\nexport function rewritePrepareScript(rootDir: string): string | undefined {\n  const packageJsonPath = path.join(rootDir, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n\n  let oldDir: string | undefined;\n\n  editJsonFile<{ scripts?: Record<string, string> }>(packageJsonPath, (pkg) => {\n    if (!pkg.scripts?.prepare) {\n      return pkg;\n    }\n\n    // Collapse \"husky install\" → \"husky\" so the ast-grep rule\n    // produces \"vp config\" with any directory argument preserved.\n    const prepare = collapseHuskyInstall(pkg.scripts.prepare);\n\n    const prepareJson = JSON.stringify({ prepare });\n    const updated = rewriteScripts(prepareJson, readPrepareRulesYaml());\n    if (updated) {\n      let newPrepare: string = JSON.parse(updated).prepare;\n      newPrepare = newPrepare.replace(\n        /\\bvp config(?:\\s+(?!-)([\\w./-]+))?/,\n        (_match: string, dir: string | undefined) => {\n          // Capture the old husky dir for hook migration.\n          // Default husky dir is .husky; custom dirs keep --hooks-dir flag.\n          oldDir = dir ?? '.husky';\n          return dir ? `vp config --hooks-dir ${dir}` : 'vp config';\n        },\n      );\n      pkg.scripts.prepare = newPrepare;\n    } else if (prepare !== pkg.scripts.prepare) {\n      // Pre-processing changed the script (husky install → husky)\n      // but no rule matched — keep the collapsed form\n      pkg.scripts.prepare = prepare;\n    }\n    return pkg;\n  });\n\n  return oldDir;\n}\n\nfunction setPackageManager(\n  projectDir: string,\n  downloadPackageManager: DownloadPackageManagerResult,\n) {\n  // set package manager\n  editJsonFile<{ packageManager?: string }>(path.join(projectDir, 'package.json'), (pkg) => {\n    if (!pkg.packageManager) {\n      pkg.packageManager = `${downloadPackageManager.name}@${downloadPackageManager.version}`;\n    }\n    return pkg;\n  });\n}\n"
  },
  {
    "path": "packages/cli/src/migration/report.ts",
    "content": "export interface MigrationReport {\n  createdViteConfigCount: number;\n  mergedConfigCount: number;\n  mergedStagedConfigCount: number;\n  inlinedLintStagedConfigCount: number;\n  removedConfigCount: number;\n  tsdownImportCount: number;\n  rewrittenImportFileCount: number;\n  rewrittenImportErrors: Array<{ path: string; message: string }>;\n  eslintMigrated: boolean;\n  prettierMigrated: boolean;\n  gitHooksConfigured: boolean;\n  warnings: string[];\n  manualSteps: string[];\n}\n\nexport function createMigrationReport(): MigrationReport {\n  return {\n    createdViteConfigCount: 0,\n    mergedConfigCount: 0,\n    mergedStagedConfigCount: 0,\n    inlinedLintStagedConfigCount: 0,\n    removedConfigCount: 0,\n    tsdownImportCount: 0,\n    rewrittenImportFileCount: 0,\n    rewrittenImportErrors: [],\n    eslintMigrated: false,\n    prettierMigrated: false,\n    gitHooksConfigured: false,\n    warnings: [],\n    manualSteps: [],\n  };\n}\n\nexport function addMigrationWarning(report: MigrationReport | undefined, warning: string) {\n  if (!report || report.warnings.includes(warning)) {\n    return;\n  }\n  report.warnings.push(warning);\n}\n\nexport function addManualStep(report: MigrationReport | undefined, step: string) {\n  if (!report || report.manualSteps.includes(step)) {\n    return;\n  }\n  report.manualSteps.push(step);\n}\n"
  },
  {
    "path": "packages/cli/src/pack-bin.ts",
    "content": "#!/usr/bin/env node\nimport module from 'node:module';\n\nimport {\n  buildWithConfigs,\n  resolveUserConfig,\n  globalLogger,\n  enableDebug,\n  type InlineConfig,\n  type ResolvedConfig,\n} from '@voidzero-dev/vite-plus-core/pack';\nimport { cac } from 'cac';\n\nimport { resolveViteConfig } from './resolve-vite-config.js';\n\n/**\n * Rolldown plugin that transforms value imports/exports to type-only in external\n * packages' .d.ts files. Some packages (e.g. postcss, lightningcss) use\n * `import { X }` and `export { X } from` instead of their type-only equivalents,\n * which causes MISSING_EXPORT warnings from the DTS bundler.\n *\n * Since .d.ts files contain only type information, all imports/exports are\n * inherently type-only, so this transformation is always safe.\n */\nconst EXTERNAL_DTS_INTERNAL_RE = /node_modules\\/(postcss|lightningcss)\\/.*\\.d\\.(ts|mts|cts)$/;\n// Match consumer .d.ts files that import from postcss/lightningcss.\n// In CI (installed from tgz): node_modules/vite-plus-core/dist/...\n// In local development (symlinked workspace): packages/core/dist/...\nconst EXTERNAL_DTS_CONSUMER_RE =\n  /(?:vite-plus-core|packages\\/core)\\/.*lightningcssOptions\\.d\\.ts$|(?:vite-plus-core|packages\\/core)\\/dist\\/.*\\.d\\.ts$/;\nconst EXTERNAL_DTS_FIX_RE = new RegExp(\n  `${EXTERNAL_DTS_INTERNAL_RE.source}|${EXTERNAL_DTS_CONSUMER_RE.source}`,\n);\n\nfunction externalDtsTypeOnlyPlugin() {\n  return {\n    name: 'vite-plus:external-dts-type-only',\n    transform: {\n      filter: { id: { include: [EXTERNAL_DTS_FIX_RE] } },\n      handler(code: string, rawId: string) {\n        // Normalize Windows backslash paths to forward slashes for regex matching\n        const id = rawId.replaceAll('\\\\', '/');\n        if (EXTERNAL_DTS_INTERNAL_RE.test(id)) {\n          // postcss/lightningcss internal files: transform imports only\n          // (exports may include value re-exports like `export const Features`)\n          return code.replace(/^(import\\s+)(?!type\\s)/gm, 'import type ');\n        }\n        // Consumer files: only transform imports from postcss/lightningcss\n        return code.replace(\n          /^(import\\s+)(?!type\\s)(.+from\\s+['\"](?:postcss|lightningcss)['\"])/gm,\n          'import type $2',\n        );\n      },\n    },\n  };\n}\n\nconst cli = cac('vp pack');\ncli.help();\n\n// support `TSDOWN_` for migration compatibility\nconst DEFAULT_ENV_PREFIXES = ['VITE_PACK_', 'TSDOWN_'];\n\ncli\n  .command('[...files]', 'Bundle files', {\n    ignoreOptionDefaultValue: true,\n    allowUnknownOptions: true,\n  })\n  // Only support config file in vite.config.ts\n  // .option('-c, --config <filename>', 'Use a custom config file')\n  .option('--config-loader <loader>', 'Config loader to use: auto, native, unrun', {\n    default: 'auto',\n  })\n  .option('--no-config', 'Disable config file')\n  .option('-f, --format <format>', 'Bundle format: esm, cjs, iife, umd', {\n    default: 'esm',\n  })\n  .option('--clean', 'Clean output directory, --no-clean to disable')\n  .option('--deps.never-bundle <module>', 'Mark dependencies as external')\n  .option('--minify', 'Minify output')\n  .option('--devtools', 'Enable devtools integration')\n  .option('--debug [feat]', 'Show debug logs')\n  .option('--target <target>', 'Bundle target, e.g \"es2015\", \"esnext\"')\n  .option('-l, --logLevel <level>', 'Set log level: info, warn, error, silent')\n  .option('--fail-on-warn', 'Fail on warnings', { default: true })\n  .option('--no-write', 'Disable writing files to disk, incompatible with watch mode')\n  .option('-d, --out-dir <dir>', 'Output directory', { default: 'dist' })\n  .option('--treeshake', 'Tree-shake bundle', { default: true })\n  .option('--sourcemap', 'Generate source map', { default: false })\n  .option('--shims', 'Enable cjs and esm shims', { default: false })\n  .option('--platform <platform>', 'Target platform', {\n    default: 'node',\n  })\n  .option('--dts', 'Generate dts files')\n  .option('--publint', 'Enable publint', { default: false })\n  .option('--attw', 'Enable Are the types wrong integration', {\n    default: false,\n  })\n  .option('--unused', 'Enable unused dependencies check', { default: false })\n  .option('-w, --watch [path]', 'Watch mode')\n  .option('--ignore-watch <path>', 'Ignore custom paths in watch mode')\n  .option('--from-vite [vitest]', 'Reuse config from Vite or Vitest')\n  .option('--report', 'Size report', { default: true })\n  .option('--env.* <value>', 'Define compile-time env variables')\n  .option(\n    '--env-file <file>',\n    'Load environment variables from a file, when used together with --env, variables in --env take precedence',\n  )\n  .option('--env-prefix <prefix>', 'Prefix for env variables to inject into the bundle', {\n    default: DEFAULT_ENV_PREFIXES,\n  })\n  .option('--on-success <command>', 'Command to run on success')\n  .option('--copy <dir>', 'Copy files to output dir')\n  .option('--public-dir <dir>', 'Alias for --copy, deprecated')\n  .option('--tsconfig <tsconfig>', 'Set tsconfig path')\n  .option('--unbundle', 'Unbundle mode')\n  .option('--root <dir>', 'Root directory of input files')\n  .option('--exe', 'Bundle as executable')\n  .option('-W, --workspace [dir]', 'Enable workspace mode')\n  .option('-F, --filter <pattern>', 'Filter configs (cwd or name), e.g. /pkg-name$/ or pkg-name')\n  .option('--exports', 'Generate export-related metadata for package.json (experimental)')\n  .action(async (input: string[], flags: InlineConfig) => {\n    if (input.length > 0) {\n      flags.entry = input;\n    }\n    if (flags.envPrefix === undefined) {\n      flags.envPrefix = DEFAULT_ENV_PREFIXES;\n    }\n\n    async function runBuild() {\n      const viteConfig = await resolveViteConfig(process.cwd(), {\n        traverseUp: flags.config !== false,\n      });\n\n      const configFiles: string[] = [];\n      if (viteConfig.configFile) {\n        configFiles.push(viteConfig.configFile);\n      }\n\n      const configs: ResolvedConfig[] = [];\n      const packConfigs = Array.isArray(viteConfig.pack)\n        ? viteConfig.pack\n        : [viteConfig.pack ?? {}];\n      for (const packConfig of packConfigs) {\n        const merged = { ...packConfig, ...flags };\n        // Inject plugin to fix MISSING_EXPORT warnings from external .d.ts files\n        // (postcss, lightningcss use `import`/`export` instead of `import type`/`export type`)\n        if (merged.dts) {\n          const existingPlugins = Array.isArray(merged.plugins) ? merged.plugins : [];\n          merged.plugins = [...existingPlugins, externalDtsTypeOnlyPlugin()];\n        }\n        const resolvedConfig = await resolveUserConfig(merged, flags);\n        configs.push(...resolvedConfig);\n      }\n\n      await buildWithConfigs(configs, configFiles, runBuild);\n    }\n\n    await runBuild();\n  });\n\nexport async function runCLI(): Promise<void> {\n  cli.parse(process.argv, { run: false });\n\n  enableDebug(cli.options.debug);\n\n  try {\n    await cli.runMatchedCommand();\n  } catch (error) {\n    globalLogger.error(error instanceof Error ? error.stack || error.message : error);\n    process.exit(1);\n  }\n}\n\nif (module.enableCompileCache) {\n  module.enableCompileCache();\n}\n\nawait runCLI();\n"
  },
  {
    "path": "packages/cli/src/pack.ts",
    "content": "import type { UserConfig as TsdownUserConfig } from '@voidzero-dev/vite-plus-core/pack';\n\nexport * from '@voidzero-dev/vite-plus-core/pack';\n\nexport interface PackUserConfig extends TsdownUserConfig {\n  /**\n   * When loading env variables from `envFile`, only include variables with these prefixes.\n   * @default ['VITE_PACK_', 'TSDOWN_']\n   */\n  envPrefix?: string | string[];\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-doc.ts",
    "content": "/**\n * VitePress tool resolver for the vite-plus CLI.\n *\n * This module exports a function that resolves the VitePress binary path\n * to the bundled VitePress in the CLI distribution. The resolved path is\n * passed back to the Rust core, which then executes VitePress with the\n * appropriate command and arguments.\n *\n * Used for: `vite doc` command\n */\n\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport { DEFAULT_ENVS } from './utils/constants.js';\n\n/**\n * Resolves the VitePress binary path and environment variables.\n *\n * @returns Promise containing:\n *   - binPath: Absolute path to the VitePress CLI entry point (vitepress.js)\n *   - envs: Environment variables to set when executing VitePress\n *\n * The function points to the bundled VitePress in the CLI's dist directory.\n */\nexport async function doc(): Promise<{\n  binPath: string;\n  envs: Record<string, string>;\n}> {\n  // VitePress's CLI binary is located at bin/vitepress.js relative to the package root\n  const binPath = join(dirname(fileURLToPath(import.meta.url)), 'vitepress', 'node', 'cli.js');\n\n  return {\n    binPath,\n    // TODO: provide envs inference API\n    envs: {\n      ...DEFAULT_ENVS,\n    },\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-fmt.ts",
    "content": "/**\n * Oxfmt tool resolver for the vite-plus CLI.\n *\n * This module exports a function that resolves the oxfmt binary path\n * using Node.js module resolution. The resolved path is passed back\n * to the Rust core, which then executes oxfmt for code formatting.\n *\n * Used for: `vite-plus fmt` command\n *\n * Oxfmt is a fast JavaScript/TypeScript formatter written in Rust that\n * provides high-performance code formatting capabilities.\n */\n\nimport { DEFAULT_ENVS, resolve } from './utils/constants.js';\n\n/**\n * Resolves the oxfmt binary path and environment variables.\n *\n * @returns Promise containing:\n *   - binPath: Absolute path to the oxfmt binary\n *   - envs: Environment variables to set when executing oxfmt\n *\n * The environment variables provide runtime context to oxfmt,\n * including Node.js version information and package manager details.\n */\nexport async function fmt(): Promise<{\n  binPath: string;\n  envs: Record<string, string>;\n}> {\n  // Resolve the oxfmt binary directly (it's a native executable)\n  const binPath = resolve('oxfmt/bin/oxfmt');\n\n  return {\n    binPath,\n    // TODO: provide envs inference API\n    envs: {\n      ...DEFAULT_ENVS,\n    },\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-lint.ts",
    "content": "/**\n * Oxlint tool resolver for the vite-plus CLI.\n *\n * This module exports a function that resolves the oxlint binary path\n * using Node.js module resolution. The resolved path is passed back\n * to the Rust core, which then executes oxlint for code linting.\n *\n * Used for: `vite-plus lint` command\n *\n * Oxlint is a fast JavaScript/TypeScript linter written in Rust that\n * provides ESLint-compatible linting with significantly better performance.\n */\n\nimport { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { relative } from 'node:path/win32';\nimport { fileURLToPath } from 'node:url';\n\nimport { DEFAULT_ENVS, resolve } from './utils/constants.js';\n\n/**\n * Resolves the oxlint binary path and environment variables.\n *\n * @returns Promise containing:\n *   - binPath: Absolute path to the oxlint binary\n *   - envs: Environment variables to set when executing oxlint\n *\n * The environment variables provide runtime context to oxlint,\n * including Node.js version information and package manager details.\n */\nexport async function lint(): Promise<{\n  binPath: string;\n  envs: Record<string, string>;\n}> {\n  // Resolve the oxlint package path first, then navigate to the bin file.\n  // The bin/oxlint subpath is not exported in package.json exports, so we\n  // resolve the main entry point and derive the bin path from it.\n  // resolve('oxlint') returns .../oxlint/dist/index.js, so we need to go up\n  // two directories (past 'dist') to reach the package root.\n  const oxlintMainPath = resolve('oxlint');\n  const oxlintPackageRoot = dirname(dirname(oxlintMainPath));\n  const binPath = join(oxlintPackageRoot, 'bin', 'oxlint');\n  let oxlintTsgolintPath = resolve('oxlint-tsgolint/bin/tsgolint');\n  if (process.platform === 'win32') {\n    // If on Windows, resolve the tsgolint binary from the local node_modules\n    oxlintTsgolintPath = join(\n      dirname(fileURLToPath(import.meta.url)),\n      '..',\n      'node_modules',\n      '.bin',\n      'tsgolint.cmd',\n    );\n    if (!existsSync(oxlintTsgolintPath)) {\n      // Fallback to the cwd node_modules\n      oxlintTsgolintPath = join(process.cwd(), 'node_modules', '.bin', 'tsgolint.cmd');\n    }\n    const relativePath = relative(process.cwd(), oxlintTsgolintPath);\n    // Only prepend .\\ if it's actually a relative path (not an absolute path returned by relative())\n    oxlintTsgolintPath = /^[a-zA-Z]:/.test(relativePath) ? relativePath : `.\\\\${relativePath}`;\n  }\n  const result = {\n    binPath,\n    // TODO: provide envs inference API\n    envs: {\n      ...DEFAULT_ENVS,\n      OXLINT_TSGOLINT_PATH: oxlintTsgolintPath,\n    },\n  };\n  return result;\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-pack.ts",
    "content": "/**\n * Tsdown tool resolver for the vite-plus CLI.\n *\n * This module exports a function that resolves the Tsdown binary path\n * using Node.js module resolution. The resolved path is passed back\n * to the Rust core, which then executes Tsdown for running pack.\n *\n * Used for: `vite-plus pack` command\n */\n\nimport { join } from 'node:path';\n\nimport { DEFAULT_ENVS } from './utils/constants.js';\n\n/**\n * Resolves the Tsdown binary path and environment variables.\n *\n * @returns Promise containing:\n *   - binPath: Absolute path to the Tsdown CLI entry point\n *   - envs: Environment variables to set when executing Tsdown\n *\n * Tsdown is a tool that provides a library for building JavaScript/TypeScript libraries.\n */\nexport async function pack(): Promise<{\n  binPath: string;\n  envs: Record<string, string>;\n}> {\n  // Resolve the bundled Tsdown CLI\n  const binPath = join(import.meta.dirname, 'pack-bin.js');\n\n  return {\n    binPath,\n    // TODO: provide envs inference API\n    envs: {\n      ...DEFAULT_ENVS,\n    },\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-test.ts",
    "content": "/**\n * Vitest tool resolver for the vite-plus CLI.\n *\n * This module exports a function that resolves the Vitest binary path\n * to the bundled Vitest in the CLI distribution. The resolved path is\n * passed back to the Rust core, which then executes Vitest for running tests.\n *\n * Used for: `vite-plus test` command\n */\n\nimport { dirname, join } from 'node:path';\n\nimport { DEFAULT_ENVS, resolve } from './utils/constants.js';\n\n/**\n * Resolves the Vitest binary path and environment variables.\n *\n * @returns Promise containing:\n *   - binPath: Absolute path to the Vitest CLI entry point (vitest.mjs)\n *   - envs: Environment variables to set when executing Vitest\n *\n * Vitest is Vite's testing framework that provides a Jest-compatible\n * testing experience with Vite's fast HMR and transformation pipeline.\n * The function points to the bundled Vitest in the CLI's dist directory.\n */\nexport async function test(): Promise<{\n  binPath: string;\n  envs: Record<string, string>;\n}> {\n  const binPath = join(dirname(resolve('@voidzero-dev/vite-plus-test')), 'dist', 'cli.js');\n\n  return {\n    binPath,\n    // Pass through source map debugging environment variable if set\n    envs: process.env.DEBUG_DISABLE_SOURCE_MAP\n      ? {\n          ...DEFAULT_ENVS,\n          DEBUG_DISABLE_SOURCE_MAP: process.env.DEBUG_DISABLE_SOURCE_MAP,\n        }\n      : {\n          ...DEFAULT_ENVS,\n        },\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-vite-config.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nconst VITE_CONFIG_FILES = [\n  'vite.config.ts',\n  'vite.config.js',\n  'vite.config.mjs',\n  'vite.config.mts',\n  'vite.config.cjs',\n  'vite.config.cts',\n];\n\n/**\n * Find a vite config file by walking up from `startDir` to `stopDir`.\n * Returns the absolute path of the first config file found, or undefined.\n */\nexport function findViteConfigUp(startDir: string, stopDir: string): string | undefined {\n  let dir = path.resolve(startDir);\n  const stop = path.resolve(stopDir);\n\n  while (true) {\n    for (const filename of VITE_CONFIG_FILES) {\n      const filePath = path.join(dir, filename);\n      if (fs.existsSync(filePath)) {\n        return filePath;\n      }\n    }\n    const parent = path.dirname(dir);\n    if (parent === dir || !parent.startsWith(stop)) {\n      break;\n    }\n    dir = parent;\n  }\n  return undefined;\n}\n\nfunction hasViteConfig(dir: string): boolean {\n  return VITE_CONFIG_FILES.some((f) => fs.existsSync(path.join(dir, f)));\n}\n\n/**\n * Find the workspace root by walking up from `startDir` looking for\n * monorepo indicators (pnpm-workspace.yaml, workspaces in package.json, lerna.json).\n */\nfunction findWorkspaceRoot(startDir: string): string | undefined {\n  let dir = path.resolve(startDir);\n  while (true) {\n    if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {\n      return dir;\n    }\n    const pkgPath = path.join(dir, 'package.json');\n    if (fs.existsSync(pkgPath)) {\n      try {\n        const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));\n        if (pkg.workspaces) {\n          return dir;\n        }\n      } catch {\n        // Skip malformed package.json and continue searching parent directories\n      }\n    }\n    if (fs.existsSync(path.join(dir, 'lerna.json'))) {\n      return dir;\n    }\n    const parent = path.dirname(dir);\n    if (parent === dir) {\n      break;\n    }\n    dir = parent;\n  }\n  return undefined;\n}\n\nexport interface ResolveViteConfigOptions {\n  traverseUp?: boolean;\n}\n\n/**\n * Resolve vite.config.ts and return the config object.\n */\nexport async function resolveViteConfig(cwd: string, options?: ResolveViteConfigOptions) {\n  const { resolveConfig } = await import('./index.js');\n\n  if (options?.traverseUp && !hasViteConfig(cwd)) {\n    const workspaceRoot = findWorkspaceRoot(cwd);\n    if (workspaceRoot) {\n      const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot);\n      if (configFile) {\n        return resolveConfig({ root: cwd, configFile }, 'build');\n      }\n    }\n  }\n\n  return resolveConfig({ root: cwd }, 'build');\n}\n\nexport async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) {\n  if (err) {\n    throw err;\n  }\n  try {\n    const config = await resolveViteConfig(viteConfigCwd);\n\n    return JSON.stringify({\n      configFile: config.configFile,\n      lint: config.lint,\n      fmt: config.fmt,\n      run: config.run,\n      staged: config.staged,\n    });\n  } catch (resolveErr) {\n    console.error('[Vite+] resolve universal vite config error:', resolveErr);\n    throw resolveErr;\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/resolve-vite.ts",
    "content": "/**\n * Vite tool resolver for the vite-plus CLI.\n *\n * This module exports a function that resolves the Vite binary path\n * using Node.js module resolution. The resolved path is passed back\n * to the Rust core, which then executes Vite with the appropriate\n * command and arguments.\n *\n * Used for: `vite-plus build` and potentially `vite-plus dev` commands\n */\n\nimport { dirname, join } from 'node:path';\n\nimport { DEFAULT_ENVS, resolve } from './utils/constants.js';\n\n/**\n * Resolves the Vite binary path and environment variables.\n *\n * @returns Promise containing:\n *   - binPath: Absolute path to the Vite CLI entry point (vite.js)\n *   - envs: Environment variables to set when executing Vite\n *\n * The function first tries to resolve vite package, then falls back\n * to vite package (for direct vite installations).\n * It constructs the path to the CLI binary within the resolved package.\n */\nexport async function vite(): Promise<{\n  binPath: string;\n  envs: Record<string, string>;\n}> {\n  // Vite's CLI binary is located at bin/vite.js relative to the package root\n  const vitePackagePath = dirname(resolve('@voidzero-dev/vite-plus-core'));\n  const binPath = join(vitePackagePath, 'cli.js');\n\n  return {\n    binPath,\n    // Pass through source map debugging environment variable if set\n    envs: process.env.DEBUG_DISABLE_SOURCE_MAP\n      ? {\n          ...DEFAULT_ENVS,\n          DEBUG_DISABLE_SOURCE_MAP: process.env.DEBUG_DISABLE_SOURCE_MAP,\n        }\n      : {\n          ...DEFAULT_ENVS,\n        },\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/run-config.ts",
    "content": "// This file is auto-generated by `cargo test`. Do not edit manually.\n\nexport type Task = {\n  /**\n   * The command to run for the task.\n   */\n  command: string;\n  /**\n   * The working directory for the task, relative to the package root (not workspace root).\n   */\n  cwd?: string;\n  /**\n   * Dependencies of this task. Use `package-name#task-name` to refer to tasks in other packages.\n   */\n  dependsOn?: Array<string>;\n} & (\n  | {\n      /**\n       * Whether to cache the task\n       */\n      cache?: true;\n      /**\n       * Environment variable names to be fingerprinted and passed to the task.\n       */\n      env?: Array<string>;\n      /**\n       * Environment variable names to be passed to the task without fingerprinting.\n       */\n      untrackedEnv?: Array<string>;\n      /**\n       * Files to include in the cache fingerprint.\n       *\n       * - Omitted: automatically tracks which files the task reads\n       * - `[]` (empty): disables file tracking entirely\n       * - Glob patterns (e.g. `\"src/**\"`) select specific files\n       * - `{auto: true}` enables automatic file tracking\n       * - Negative patterns (e.g. `\"!dist/**\"`) exclude matched files\n       *\n       * Patterns are relative to the package directory.\n       */\n      input?: Array<\n        | string\n        | {\n            /**\n             * Automatically track which files the task reads\n             */\n            auto: boolean;\n          }\n      >;\n    }\n  | {\n      /**\n       * Whether to cache the task\n       */\n      cache: false;\n    }\n);\n\nexport type UserGlobalCacheConfig =\n  | boolean\n  | {\n      /**\n       * Enable caching for package.json scripts not defined in the `tasks` map.\n       *\n       * When `false`, package.json scripts will not be cached.\n       * When `true`, package.json scripts will be cached with default settings.\n       *\n       * Default: `false`\n       */\n      scripts?: boolean;\n      /**\n       * Global cache kill switch for task entries.\n       *\n       * When `false`, overrides all tasks to disable caching, even tasks with `cache: true`.\n       * When `true`, respects each task's individual `cache` setting\n       * (each task's `cache` defaults to `true` if omitted).\n       *\n       * Default: `true`\n       */\n      tasks?: boolean;\n    };\n\nexport type RunConfig = {\n  /**\n   * Root-level cache configuration.\n   *\n   * This option can only be set in the workspace root's config file.\n   * Setting it in a package's config will result in an error.\n   */\n  cache?: UserGlobalCacheConfig;\n  /**\n   * Task definitions\n   */\n  tasks?: { [key in string]?: Task };\n  /**\n   * Whether to automatically run `preX`/`postX` package.json scripts as\n   * lifecycle hooks when script `X` is executed.\n   *\n   * When `true` (the default), running script `test` will automatically\n   * run `pretest` before and `posttest` after, if they exist.\n   *\n   * This option can only be set in the workspace root's config file.\n   * Setting it in a package's config will result in an error.\n   */\n  enablePrePostScripts?: boolean;\n};\n"
  },
  {
    "path": "packages/cli/src/staged/bin.ts",
    "content": "// Runs staged linters on staged files using the lint-staged programmatic API.\n// Bundled by rolldown — no runtime dependency needed in user projects.\n//\n// Reads the \"staged\" key from vite.config.ts via resolveConfig() and passes it\n// to lint-staged as an explicit config object.  Exits with a warning if no\n// staged config is found.\n//\n// We use the programmatic API instead of importing lint-staged/bin because\n// lint-staged's dependency tree includes CJS modules that use require('node:events')\n// etc., which breaks when bundled to ESM format by rolldown.\n\nimport lintStaged from 'lint-staged';\nimport type { Configuration, Options } from 'lint-staged';\nimport mri from 'mri';\n\nimport { vitePlusHeader } from '../../binding/index.js';\nimport { resolveViteConfig } from '../resolve-vite-config.js';\nimport { renderCliDoc } from '../utils/help.js';\nimport { errorMsg, log } from '../utils/terminal.js';\n\nconst args = mri(process.argv.slice(3), {\n  alias: {\n    h: 'help',\n    p: 'concurrent',\n    d: 'debug',\n    q: 'quiet',\n    r: 'relative',\n    v: 'verbose',\n  },\n  boolean: [\n    'help',\n    'allow-empty',\n    'debug',\n    'continue-on-error',\n    'fail-on-changes',\n    'hide-partially-staged',\n    'hide-unstaged',\n    'quiet',\n    'relative',\n    'revert',\n    'stash',\n    'verbose',\n  ],\n  string: ['concurrent', 'cwd', 'diff', 'diff-filter'],\n});\n\nif (args.help) {\n  const helpMessage = renderCliDoc({\n    usage: 'vp staged [options]',\n    summary: 'Run linters on staged files using staged config from vite.config.ts.',\n    documentationUrl: 'https://viteplus.dev/guide/commit-hooks',\n    sections: [\n      {\n        title: 'Options',\n        rows: [\n          {\n            label: '--allow-empty',\n            description: 'Allow empty commits when tasks revert all staged changes',\n          },\n          {\n            label: '-p, --concurrent <number|boolean>',\n            description: 'Number of tasks to run concurrently, or false for serial',\n          },\n          {\n            label: '--continue-on-error',\n            description: 'Run all tasks to completion even if one fails',\n          },\n          { label: '--cwd <path>', description: 'Working directory to run all tasks in' },\n          { label: '-d, --debug', description: 'Enable debug output' },\n          {\n            label: '--diff <string>',\n            description: 'Override the default --staged flag of git diff',\n          },\n          {\n            label: '--diff-filter <string>',\n            description: 'Override the default --diff-filter=ACMR flag of git diff',\n          },\n          {\n            label: '--fail-on-changes',\n            description: 'Fail with exit code 1 when tasks modify tracked files',\n          },\n          {\n            label: '--hide-partially-staged',\n            description: 'Hide unstaged changes from partially staged files',\n          },\n          {\n            label: '--hide-unstaged',\n            description: 'Hide all unstaged changes before running tasks',\n          },\n          { label: '--no-stash', description: 'Disable the backup stash' },\n          { label: '-q, --quiet', description: 'Disable console output' },\n          { label: '-r, --relative', description: 'Pass filepaths relative to cwd to tasks' },\n          { label: '--revert', description: 'Revert to original state in case of errors' },\n          { label: '-v, --verbose', description: 'Show task output even when tasks succeed' },\n          { label: '-h, --help', description: 'Show this help message' },\n        ],\n      },\n    ],\n  });\n  log(vitePlusHeader() + '\\n');\n  log(helpMessage);\n} else {\n  const options: Options = {};\n\n  // Boolean flags — only include if explicitly set\n  if (args['allow-empty'] != null) {\n    options.allowEmpty = args['allow-empty'];\n  }\n  if (args.debug != null) {\n    options.debug = args.debug;\n  }\n  if (args['continue-on-error'] != null) {\n    options.continueOnError = args['continue-on-error'];\n  }\n  if (args['fail-on-changes'] != null) {\n    options.failOnChanges = args['fail-on-changes'];\n  }\n  if (args['hide-partially-staged'] != null) {\n    options.hidePartiallyStaged = args['hide-partially-staged'];\n  }\n  if (args['hide-unstaged'] != null) {\n    options.hideUnstaged = args['hide-unstaged'];\n  }\n  if (args.quiet != null) {\n    options.quiet = args.quiet;\n  }\n  if (args.relative != null) {\n    options.relative = args.relative;\n  }\n  if (args.revert != null) {\n    options.revert = args.revert;\n  }\n  if (args.stash != null) {\n    options.stash = args.stash;\n  }\n  if (args.verbose != null) {\n    options.verbose = args.verbose;\n  }\n\n  // Read \"staged\" from vite.config.ts and pass it as an inline config object to lint-staged.\n  let stagedConfig;\n  try {\n    const viteConfig = await resolveViteConfig(args.cwd ?? process.cwd());\n    stagedConfig = viteConfig.staged;\n  } catch (err) {\n    // Surface real errors (syntax errors, missing imports, etc.)\n    // instead of masking them as \"no config found\"\n    const message = err instanceof Error ? err.message : String(err);\n    log(`Failed to load vite.config: ${message}`);\n    process.exit(1);\n  }\n  if (stagedConfig) {\n    options.config = stagedConfig as Configuration;\n  } else {\n    log(vitePlusHeader() + '\\n');\n    errorMsg('No \"staged\" config found in vite.config.ts. Please add a staged config:');\n    log('');\n    log('  // vite.config.ts');\n    log('  export default defineConfig({');\n    log(\"    staged: { '*': 'vp check --fix' },\");\n    log('  });');\n    process.exit(1);\n  }\n  if (args.cwd != null) {\n    options.cwd = args.cwd;\n  }\n  if (args.diff != null) {\n    options.diff = args.diff;\n  }\n  if (args['diff-filter'] != null) {\n    options.diffFilter = args['diff-filter'];\n  }\n\n  // Parsed flags: concurrent → boolean | number\n  if (args.concurrent != null) {\n    const val = args.concurrent;\n    if (val === 'true') {\n      options.concurrent = true;\n    } else if (val === 'false') {\n      options.concurrent = false;\n    } else {\n      const num = Number(val);\n      options.concurrent = Number.isNaN(num) || val === '' ? true : num;\n    }\n  }\n\n  const success = await lintStaged(options);\n  process.exit(success ? 0 : 1);\n}\n"
  },
  {
    "path": "packages/cli/src/staged-config.ts",
    "content": "export type StagedConfig = Record<string, string | string[]>;\n"
  },
  {
    "path": "packages/cli/src/types/index.ts",
    "content": "export * from './package.js';\nexport * from './workspace.js';\n"
  },
  {
    "path": "packages/cli/src/types/package.ts",
    "content": "export const PackageManager = {\n  pnpm: 'pnpm',\n  npm: 'npm',\n  yarn: 'yarn',\n} as const;\nexport type PackageManager = (typeof PackageManager)[keyof typeof PackageManager];\n\nexport const DependencyType = {\n  dependencies: 'dependencies',\n  devDependencies: 'devDependencies',\n  peerDependencies: 'peerDependencies',\n  optionalDependencies: 'optionalDependencies',\n} as const;\nexport type DependencyType = (typeof DependencyType)[keyof typeof DependencyType];\n"
  },
  {
    "path": "packages/cli/src/types/workspace.ts",
    "content": "import type { DownloadPackageManagerResult } from '../../binding/index.js';\nimport type { PackageManager } from './package.js';\n\nexport interface WorkspacePackage {\n  name: string;\n  // The path of the package relative to the workspace root\n  path: string;\n  description?: string;\n  version?: string;\n  isTemplatePackage: boolean;\n}\n\nexport interface WorkspaceInfo {\n  rootDir: string;\n  isMonorepo: boolean;\n  // The scope of the monorepo, e.g. @my\n  // This is used to determine the scope of the generated package\n  // For example, if the monorepo scope is @my, then the generated package will be @my/my-package\n  monorepoScope: string;\n  // The patterns of the workspace packages\n  // For example, [\"apps/*\", \"packages/*\", \"services/*\", \"tools/*\"]\n  workspacePatterns: string[];\n  // The parent directories of the generated package\n  // For example, [\"apps\", \"packages\", \"services\", \"tools\"]\n  parentDirs: string[];\n  packageManager: PackageManager;\n  packageManagerVersion: string;\n  downloadPackageManager: DownloadPackageManagerResult;\n  packages: WorkspacePackage[];\n}\n\nexport interface WorkspaceInfoOptional extends Omit<\n  WorkspaceInfo,\n  'packageManager' | 'downloadPackageManager'\n> {\n  packageManager?: PackageManager;\n}\n"
  },
  {
    "path": "packages/cli/src/utils/__tests__/agent.spec.ts",
    "content": "import fs from 'node:fs';\nimport fsPromises from 'node:fs/promises';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\nimport { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';\n\nimport {\n  detectExistingAgentTargetPaths,\n  detectExistingAgentTargetPath,\n  hasExistingAgentInstructions,\n  replaceMarkedAgentInstructionsSection,\n  resolveAgentTargetPaths,\n  writeAgentInstructions,\n} from '../agent.js';\nimport { pkgRoot } from '../path.js';\n\ntype MockNode =\n  | { kind: 'dir' }\n  | { kind: 'file'; content: string }\n  | { kind: 'symlink'; target: string };\n\nclass InMemoryFs {\n  private readonly nodes = new Map<string, MockNode>();\n\n  constructor() {\n    this.ensureDirectory(path.parse(process.cwd()).root);\n  }\n\n  existsSync(filePath: fs.PathLike): boolean {\n    return this.nodes.has(this.normalize(filePath));\n  }\n\n  lstatSync(filePath: fs.PathLike): fs.Stats {\n    const node = this.getNode(filePath);\n    return {\n      isSymbolicLink: () => node.kind === 'symlink',\n    } as fs.Stats;\n  }\n\n  async lstat(filePath: fs.PathLike): Promise<fs.Stats> {\n    return this.lstatSync(filePath);\n  }\n\n  async mkdir(dirPath: fs.PathLike, options: { recursive: true }): Promise<void> {\n    if (!options.recursive) {\n      throw new Error('Only recursive mkdir is supported in tests');\n    }\n    this.ensureDirectory(this.normalize(dirPath));\n  }\n\n  async readFile(filePath: fs.PathLike): Promise<string> {\n    const resolvedPath = this.resolvePath(filePath);\n    const node = this.nodes.get(resolvedPath);\n    if (!node || node.kind !== 'file') {\n      throw new Error(`ENOENT: no such file \"${String(filePath)}\"`);\n    }\n    return node.content;\n  }\n\n  async writeFile(filePath: fs.PathLike, content: string): Promise<void> {\n    const resolvedPath = this.resolvePathForWrite(filePath);\n    this.ensureDirectory(path.dirname(resolvedPath));\n    this.nodes.set(resolvedPath, { kind: 'file', content });\n  }\n\n  async appendFile(filePath: fs.PathLike, content: string): Promise<void> {\n    const resolvedPath = this.resolvePathForWrite(filePath);\n    this.ensureDirectory(path.dirname(resolvedPath));\n    const existing = this.nodes.get(resolvedPath);\n    if (!existing) {\n      this.nodes.set(resolvedPath, { kind: 'file', content });\n      return;\n    }\n    if (existing.kind !== 'file') {\n      throw new Error(`EISDIR: cannot append to non-file \"${String(filePath)}\"`);\n    }\n    existing.content += content;\n  }\n\n  async realpath(filePath: fs.PathLike): Promise<string> {\n    return this.resolvePath(filePath);\n  }\n\n  async symlink(target: string, filePath: fs.PathLike): Promise<void> {\n    const normalizedPath = this.normalize(filePath);\n    this.ensureDirectory(path.dirname(normalizedPath));\n    this.nodes.set(normalizedPath, { kind: 'symlink', target });\n  }\n\n  async readlink(filePath: fs.PathLike): Promise<string> {\n    const node = this.getNode(filePath);\n    if (node.kind !== 'symlink') {\n      throw new Error(`EINVAL: not a symlink \"${String(filePath)}\"`);\n    }\n    return node.target;\n  }\n\n  async unlink(filePath: fs.PathLike): Promise<void> {\n    const normalizedPath = this.normalize(filePath);\n    if (!this.nodes.has(normalizedPath)) {\n      throw new Error(`ENOENT: no such file \"${String(filePath)}\"`);\n    }\n    this.nodes.delete(normalizedPath);\n  }\n\n  readFileSync(filePath: fs.PathLike): string {\n    const resolvedPath = this.resolvePath(filePath);\n    const node = this.nodes.get(resolvedPath);\n    if (!node || node.kind !== 'file') {\n      throw new Error(`ENOENT: no such file \"${String(filePath)}\"`);\n    }\n    return node.content;\n  }\n\n  isSymlink(filePath: string): boolean {\n    return this.lstatSync(filePath).isSymbolicLink();\n  }\n\n  readlinkSync(filePath: string): string {\n    const node = this.getNode(filePath);\n    if (node.kind !== 'symlink') {\n      throw new Error(`EINVAL: not a symlink \"${filePath}\"`);\n    }\n    return node.target;\n  }\n\n  async readText(filePath: string): Promise<string> {\n    return this.readFile(filePath);\n  }\n\n  private normalize(filePath: fs.PathLike): string {\n    return path.resolve(String(filePath));\n  }\n\n  private getNode(filePath: fs.PathLike): MockNode {\n    const normalizedPath = this.normalize(filePath);\n    const node = this.nodes.get(normalizedPath);\n    if (!node) {\n      throw new Error(`ENOENT: no such file \"${String(filePath)}\"`);\n    }\n    return node;\n  }\n\n  private ensureDirectory(dirPath: string): void {\n    const normalizedPath = path.resolve(dirPath);\n    const root = path.parse(normalizedPath).root;\n    let current = root;\n    this.nodes.set(root, { kind: 'dir' });\n\n    const segments = path.relative(root, normalizedPath).split(path.sep).filter(Boolean);\n    for (const segment of segments) {\n      current = path.join(current, segment);\n      const node = this.nodes.get(current);\n      if (!node) {\n        this.nodes.set(current, { kind: 'dir' });\n        continue;\n      }\n      if (node.kind !== 'dir') {\n        throw new Error(`ENOTDIR: \"${current}\" is not a directory`);\n      }\n    }\n  }\n\n  private resolvePath(filePath: fs.PathLike): string {\n    let current = this.normalize(filePath);\n    const visited = new Set<string>();\n\n    while (true) {\n      const node = this.nodes.get(current);\n      if (!node) {\n        throw new Error(`ENOENT: no such file \"${String(filePath)}\"`);\n      }\n      if (node.kind !== 'symlink') {\n        return current;\n      }\n      if (visited.has(current)) {\n        throw new Error(`ELOOP: too many symlink levels \"${String(filePath)}\"`);\n      }\n      visited.add(current);\n      current = path.resolve(path.dirname(current), node.target);\n    }\n  }\n\n  private resolvePathForWrite(filePath: fs.PathLike): string {\n    const normalizedPath = this.normalize(filePath);\n    const node = this.nodes.get(normalizedPath);\n    if (node?.kind === 'symlink') {\n      return path.resolve(path.dirname(normalizedPath), node.target);\n    }\n    return normalizedPath;\n  }\n}\n\nconst AGENT_TEMPLATE = ['<!--VITE PLUS START-->', 'template block', '<!--VITE PLUS END-->'].join(\n  '\\n',\n);\n\nlet mockFs: InMemoryFs;\nlet projectIndex = 0;\n\nbeforeEach(async () => {\n  vi.spyOn(prompts.log, 'message').mockImplementation(() => {});\n\n  mockFs = new InMemoryFs();\n  projectIndex = 0;\n\n  vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => mockFs.existsSync(filePath));\n  vi.spyOn(fs, 'lstatSync').mockImplementation((filePath) => mockFs.lstatSync(filePath));\n  vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) =>\n    mockFs.readFileSync(filePath as fs.PathLike),\n  );\n\n  vi.spyOn(fsPromises, 'appendFile').mockImplementation(async (filePath, data) =>\n    mockFs.appendFile(filePath as fs.PathLike, String(data)),\n  );\n  vi.spyOn(fsPromises, 'lstat').mockImplementation(async (filePath) => mockFs.lstat(filePath));\n  vi.spyOn(fsPromises, 'mkdir').mockImplementation(async (filePath, options) => {\n    await mockFs.mkdir(filePath, options as { recursive: true });\n    return undefined;\n  });\n  vi.spyOn(fsPromises, 'readFile').mockImplementation(async (filePath) =>\n    mockFs.readFile(filePath as fs.PathLike),\n  );\n  vi.spyOn(fsPromises, 'readlink').mockImplementation(async (filePath) =>\n    mockFs.readlink(filePath),\n  );\n  vi.spyOn(fsPromises, 'realpath').mockImplementation(async (filePath) =>\n    mockFs.realpath(filePath),\n  );\n  vi.spyOn(fsPromises, 'symlink').mockImplementation(async (target, filePath) => {\n    await mockFs.symlink(String(target), filePath);\n  });\n  vi.spyOn(fsPromises, 'unlink').mockImplementation(async (filePath) => {\n    await mockFs.unlink(filePath);\n  });\n  vi.spyOn(fsPromises, 'writeFile').mockImplementation(async (filePath, data) => {\n    await mockFs.writeFile(filePath as fs.PathLike, String(data as string));\n  });\n\n  await mockFs.writeFile(path.join(pkgRoot, 'AGENTS.md'), AGENT_TEMPLATE);\n});\n\nafterEach(() => {\n  vi.restoreAllMocks();\n});\n\nasync function createProjectDir() {\n  const dir = path.join(pkgRoot, '__virtual__', `project-${projectIndex++}`);\n  await mockFs.mkdir(dir, { recursive: true });\n  return dir;\n}\n\ndescribe('resolveAgentTargetPaths', () => {\n  it('resolves comma-separated agent names and deduplicates target paths', () => {\n    expect(resolveAgentTargetPaths('claude,amp,opencode,chatgpt')).toEqual([\n      'CLAUDE.md',\n      'AGENTS.md',\n    ]);\n  });\n\n  it('resolves repeated --agent values and trims whitespace', () => {\n    expect(resolveAgentTargetPaths([' claude ', ' amp, opencode ', 'codex'])).toEqual([\n      'CLAUDE.md',\n      'AGENTS.md',\n    ]);\n  });\n\n  it('falls back to AGENTS.md when no valid agents are provided', () => {\n    expect(resolveAgentTargetPaths()).toEqual(['AGENTS.md']);\n    expect(resolveAgentTargetPaths(' , , ')).toEqual(['AGENTS.md']);\n  });\n});\n\ndescribe('detectExistingAgentTargetPath', () => {\n  it('detects all existing regular agent files', async () => {\n    const dir = await createProjectDir();\n    await mockFs.writeFile(path.join(dir, 'AGENTS.md'), '# Agents');\n    await mockFs.writeFile(path.join(dir, 'CLAUDE.md'), '# Claude');\n\n    expect(detectExistingAgentTargetPaths(dir)).toEqual(['AGENTS.md', 'CLAUDE.md']);\n  });\n\n  it('detects existing regular agent files', async () => {\n    const dir = await createProjectDir();\n    await mockFs.writeFile(path.join(dir, 'CLAUDE.md'), '# Claude');\n\n    expect(detectExistingAgentTargetPath(dir)).toBe('CLAUDE.md');\n  });\n\n  it('ignores symlinked agent files', async () => {\n    const dir = await createProjectDir();\n    await mockFs.symlink('AGENTS.md', path.join(dir, 'CLAUDE.md'));\n\n    expect(detectExistingAgentTargetPath(dir)).toBeUndefined();\n  });\n});\n\ndescribe('replaceMarkedAgentInstructionsSection', () => {\n  it('replaces the marker block when markers are present in both files', () => {\n    const existing = [\n      '# Local instructions',\n      '<!--VITE PLUS START-->',\n      'old block',\n      '<!--VITE PLUS END-->',\n      '# Footer',\n    ].join('\\n');\n    const incoming = ['<!--VITE PLUS START-->', 'new block', '<!--VITE PLUS END-->'].join('\\n');\n\n    expect(replaceMarkedAgentInstructionsSection(existing, incoming)).toBe(\n      [\n        '# Local instructions',\n        '<!--VITE PLUS START-->',\n        'new block',\n        '<!--VITE PLUS END-->',\n        '# Footer',\n      ].join('\\n'),\n    );\n  });\n\n  it('returns undefined when markers are missing in existing content', () => {\n    expect(\n      replaceMarkedAgentInstructionsSection(\n        'no markers here',\n        '<!--VITE PLUS START-->\\nnew\\n<!--VITE PLUS END-->',\n      ),\n    ).toBeUndefined();\n  });\n});\n\ndescribe('writeAgentInstructions symlink behavior', () => {\n  it('links non-standard agent files to AGENTS.md when AGENTS.md is selected', async () => {\n    const dir = await createProjectDir();\n\n    await writeAgentInstructions({\n      projectRoot: dir,\n      targetPaths: ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.github/copilot-instructions.md'],\n      interactive: false,\n    });\n\n    expect(mockFs.isSymlink(path.join(dir, 'AGENTS.md'))).toBe(false);\n    expect(mockFs.isSymlink(path.join(dir, 'CLAUDE.md'))).toBe(true);\n    expect(mockFs.readlinkSync(path.join(dir, 'CLAUDE.md'))).toBe('AGENTS.md');\n    expect(mockFs.isSymlink(path.join(dir, 'GEMINI.md'))).toBe(true);\n    expect(mockFs.readlinkSync(path.join(dir, 'GEMINI.md'))).toBe('AGENTS.md');\n    expect(mockFs.isSymlink(path.join(dir, '.github/copilot-instructions.md'))).toBe(true);\n    expect(mockFs.readlinkSync(path.join(dir, '.github/copilot-instructions.md'))).toBe(\n      path.join('..', 'AGENTS.md'),\n    );\n  });\n\n  it('falls back to copy when symlink throws EPERM (Windows without admin)', async () => {\n    const dir = await createProjectDir();\n    const symlinkSpy = vi.spyOn(fsPromises, 'symlink');\n    const copyFileSpy = vi.spyOn(fsPromises, 'copyFile').mockResolvedValue(undefined);\n\n    // Make symlink throw EPERM (Windows behavior without admin privileges)\n    symlinkSpy.mockRejectedValue(\n      Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }),\n    );\n\n    await writeAgentInstructions({\n      projectRoot: dir,\n      targetPaths: ['AGENTS.md', 'CLAUDE.md', '.github/copilot-instructions.md'],\n      interactive: false,\n    });\n\n    // AGENTS.md should be written as a regular file (not symlinked)\n    expect(mockFs.existsSync(path.join(dir, 'AGENTS.md'))).toBe(true);\n\n    // Non-standard paths should fall back to copyFile since symlink failed\n    expect(copyFileSpy).toHaveBeenCalledWith(\n      path.join(dir, 'AGENTS.md'),\n      path.join(dir, 'CLAUDE.md'),\n    );\n    expect(copyFileSpy).toHaveBeenCalledWith(\n      path.join(dir, 'AGENTS.md'),\n      path.join(dir, '.github', 'copilot-instructions.md'),\n    );\n  });\n\n  it('does not replace existing non-symlink files with symlinks', async () => {\n    const dir = await createProjectDir();\n    const existingClaude = path.join(dir, 'CLAUDE.md');\n    await mockFs.writeFile(existingClaude, 'existing claude instructions');\n\n    await writeAgentInstructions({\n      projectRoot: dir,\n      targetPaths: ['AGENTS.md', 'CLAUDE.md'],\n      interactive: false,\n    });\n\n    expect(mockFs.isSymlink(existingClaude)).toBe(false);\n    expect(await mockFs.readText(existingClaude)).toBe('existing claude instructions');\n    expect(mockFs.existsSync(path.join(dir, 'AGENTS.md'))).toBe(true);\n  });\n\n  it('silently updates marker blocks without prompting in interactive mode', async () => {\n    const dir = await createProjectDir();\n    const targetPath = path.join(dir, 'AGENTS.md');\n    const existing = [\n      '# Local',\n      '<!--VITE PLUS START-->',\n      'old block',\n      '<!--VITE PLUS END-->',\n    ].join('\\n');\n    await mockFs.writeFile(targetPath, existing);\n\n    const selectSpy = vi.spyOn(prompts, 'select');\n    const successSpy = vi.spyOn(prompts.log, 'success');\n\n    await writeAgentInstructions({\n      projectRoot: dir,\n      targetPaths: ['AGENTS.md'],\n      interactive: true,\n    });\n\n    expect(selectSpy).not.toHaveBeenCalled();\n    expect(await mockFs.readText(targetPath)).toContain('template block');\n    expect(successSpy).not.toHaveBeenCalledWith('Updated agent instructions in AGENTS.md');\n  });\n});\n\ndescribe('hasExistingAgentInstructions', () => {\n  it('returns true when an agent file has start marker', async () => {\n    const dir = await createProjectDir();\n    await mockFs.writeFile(\n      path.join(dir, 'AGENTS.md'),\n      '<!--VITE PLUS START-->\\ncontent\\n<!--VITE PLUS END-->',\n    );\n    expect(hasExistingAgentInstructions(dir)).toBe(true);\n  });\n\n  it('returns true when CLAUDE.md has start marker', async () => {\n    const dir = await createProjectDir();\n    await mockFs.writeFile(\n      path.join(dir, 'CLAUDE.md'),\n      '<!--VITE PLUS START-->\\ncontent\\n<!--VITE PLUS END-->',\n    );\n    expect(hasExistingAgentInstructions(dir)).toBe(true);\n  });\n\n  it('returns false when files exist without markers', async () => {\n    const dir = await createProjectDir();\n    await mockFs.writeFile(path.join(dir, 'AGENTS.md'), '# No markers here');\n    expect(hasExistingAgentInstructions(dir)).toBe(false);\n  });\n\n  it('returns false when no files exist', async () => {\n    const dir = await createProjectDir();\n    expect(hasExistingAgentInstructions(dir)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/utils/__tests__/editor.spec.ts",
    "content": "import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it } from 'vitest';\n\nimport { writeEditorConfigs } from '../editor.js';\n\nconst tempDirs: string[] = [];\n\nfunction createTempDir() {\n  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-editor-config-'));\n  tempDirs.push(dir);\n  return dir;\n}\n\nafterEach(() => {\n  for (const dir of tempDirs.splice(0, tempDirs.length)) {\n    fs.rmSync(dir, { recursive: true, force: true });\n  }\n});\n\ndescribe('writeEditorConfigs', () => {\n  it('writes vscode settings that align formatter config with vite.config.ts', async () => {\n    const projectRoot = createTempDir();\n\n    await writeEditorConfigs({\n      projectRoot,\n      editorId: 'vscode',\n      interactive: false,\n      silent: true,\n    });\n\n    const settings = JSON.parse(\n      fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'),\n    ) as Record<string, unknown>;\n\n    expect(settings['editor.defaultFormatter']).toBe('oxc.oxc-vscode');\n    expect(settings['oxc.fmt.configPath']).toBe('./vite.config.ts');\n    expect(settings['editor.formatOnSave']).toBe(true);\n  });\n\n  it('merges existing vscode JSONC settings (comments, trailing commas)', async () => {\n    const projectRoot = createTempDir();\n\n    const vscodeDir = path.join(projectRoot, '.vscode');\n    fs.mkdirSync(vscodeDir, { recursive: true });\n    fs.writeFileSync(\n      path.join(vscodeDir, 'settings.json'),\n      `{\n  // JSONC comment\n  \"editor.formatOnSave\": false,\n  \"editor.codeActionsOnSave\": {\n    // preserve existing key\n    \"source.organizeImports\": \"explicit\",\n  },\n}\n`,\n      'utf8',\n    );\n\n    await writeEditorConfigs({\n      projectRoot,\n      editorId: 'vscode',\n      interactive: false,\n      silent: true,\n    });\n\n    const settings = JSON.parse(\n      fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'),\n    ) as Record<string, unknown>;\n\n    // Existing key is preserved (merge never overwrites)\n    expect(settings['editor.formatOnSave']).toBe(false);\n\n    // New keys are added\n    expect(settings['editor.defaultFormatter']).toBe('oxc.oxc-vscode');\n    expect(settings['oxc.fmt.configPath']).toBe('./vite.config.ts');\n\n    const codeActions = settings['editor.codeActionsOnSave'] as Record<string, unknown>;\n    expect(codeActions['source.organizeImports']).toBe('explicit');\n    expect(codeActions['source.fixAll.oxc']).toBe('explicit');\n  });\n\n  it('writes zed settings that align formatter config with vite.config.ts', async () => {\n    const projectRoot = createTempDir();\n\n    await writeEditorConfigs({\n      projectRoot,\n      editorId: 'zed',\n      interactive: false,\n      silent: true,\n    });\n\n    const settings = JSON.parse(\n      fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'),\n    ) as {\n      lsp?: {\n        oxfmt?: {\n          initialization_options?: {\n            settings?: {\n              configPath?: string;\n            };\n          };\n        };\n      };\n    };\n\n    expect(settings.lsp?.oxfmt?.initialization_options?.settings?.configPath).toBe(\n      './vite.config.ts',\n    );\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/utils/__tests__/help.spec.ts",
    "content": "import { stripVTControlCharacters } from 'node:util';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { renderCliDoc } from '../help.js';\n\ndescribe('renderCliDoc', () => {\n  it('renders usage, rows, and sections with stable spacing', () => {\n    const output = renderCliDoc(\n      {\n        usage: 'vp demo <name>',\n        summary: 'Create a demo project.',\n        sections: [\n          {\n            title: 'Arguments',\n            rows: [\n              {\n                label: '<name>',\n                description: ['Project name', 'Must be kebab-case'],\n              },\n            ],\n          },\n          {\n            title: 'Options',\n            rows: [{ label: '-h, --help', description: 'Print help' }],\n          },\n          {\n            title: 'Examples',\n            lines: ['  vp demo my-app'],\n          },\n        ],\n      },\n      { color: false },\n    );\n\n    expect(output).toMatchInlineSnapshot(`\n      \"Usage: vp demo <name>\n\n      Create a demo project.\n\n      Arguments:\n        <name>  Project name\n                Must be kebab-case\n\n      Options:\n        -h, --help  Print help\n\n      Examples:\n        vp demo my-app\n      \"\n    `);\n  });\n\n  it('renders section-only documents without usage prelude', () => {\n    const output = renderCliDoc(\n      {\n        sections: [\n          {\n            title: 'Package Versions',\n            rows: [{ label: 'global vite-plus', description: 'v0.1.0' }],\n          },\n        ],\n      },\n      { color: false },\n    );\n\n    expect(output).toMatchInlineSnapshot(`\n      \"Package Versions:\n        global vite-plus  v0.1.0\n      \"\n    `);\n  });\n\n  it('renders documentation footer when present', () => {\n    const output = renderCliDoc({\n      usage: 'vp demo',\n      documentationUrl: 'https://viteplus.dev/guide/demo',\n      sections: [{ title: 'Arguments', rows: [{ label: '<name>', description: 'Project name' }] }],\n    });\n\n    expect(stripVTControlCharacters(output)).toContain(\n      'Documentation: https://viteplus.dev/guide/demo',\n    );\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/utils/__tests__/package.spec.ts",
    "content": "import { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport { checkNpmPackageExists } from '../package.js';\n\ndescribe('checkNpmPackageExists', () => {\n  afterEach(() => {\n    vi.restoreAllMocks();\n  });\n\n  it('returns true when package exists (200)', async () => {\n    vi.spyOn(globalThis, 'fetch').mockResolvedValue({ status: 200, ok: true } as Response);\n    expect(await checkNpmPackageExists('create-vite')).toBe(true);\n  });\n\n  it('returns false when package does not exist (404)', async () => {\n    vi.spyOn(globalThis, 'fetch').mockResolvedValue({ status: 404, ok: false } as Response);\n    expect(await checkNpmPackageExists('create-vite-plus-app')).toBe(false);\n  });\n\n  it('returns true on network error', async () => {\n    vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'));\n    expect(await checkNpmPackageExists('create-vite')).toBe(true);\n  });\n\n  it('strips version from unscoped package name', async () => {\n    const mockFetch = vi\n      .spyOn(globalThis, 'fetch')\n      .mockResolvedValue({ status: 200, ok: true } as Response);\n    await checkNpmPackageExists('create-vite@latest');\n    expect(mockFetch).toHaveBeenCalledWith(\n      'https://registry.npmjs.org/create-vite',\n      expect.objectContaining({ method: 'HEAD' }),\n    );\n  });\n\n  it('strips version from scoped package name', async () => {\n    const mockFetch = vi\n      .spyOn(globalThis, 'fetch')\n      .mockResolvedValue({ status: 200, ok: true } as Response);\n    await checkNpmPackageExists('@tanstack/create-start@latest');\n    expect(mockFetch).toHaveBeenCalledWith(\n      'https://registry.npmjs.org/@tanstack/create-start',\n      expect.objectContaining({ method: 'HEAD' }),\n    );\n  });\n\n  it('does not strip scope from scoped package without version', async () => {\n    const mockFetch = vi\n      .spyOn(globalThis, 'fetch')\n      .mockResolvedValue({ status: 200, ok: true } as Response);\n    await checkNpmPackageExists('@tanstack/create-start');\n    expect(mockFetch).toHaveBeenCalledWith(\n      'https://registry.npmjs.org/@tanstack/create-start',\n      expect.objectContaining({ method: 'HEAD' }),\n    );\n  });\n});\n"
  },
  {
    "path": "packages/cli/src/utils/agent.ts",
    "content": "import fs from 'node:fs';\nimport fsPromises from 'node:fs/promises';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\n\nimport { pkgRoot } from './path.js';\n\n// --- Interfaces ---\n\nexport interface McpConfigTarget {\n  /** Config file path relative to project root, e.g. \".claude/settings.json\" */\n  filePath: string;\n  /** JSON key that holds MCP server entries, e.g. \"mcpServers\" or \"servers\" */\n  rootKey: string;\n  /** Extra fields merged into the server entry, e.g. { type: \"stdio\" } for VS Code */\n  extraFields?: Record<string, string>;\n}\n\nexport interface AgentConfig {\n  displayName: string;\n  skillsDir: string;\n  detect: (root: string) => boolean;\n  /** Project-level config files where MCP server entries can be auto-written */\n  mcpConfig?: McpConfigTarget[];\n  /** Fallback hint printed when the agent has no project-level config support */\n  mcpHint?: string;\n}\n\n// --- Agent registry ---\n\nconst DEFAULT_MCP_HINT =\n  \"Run `npx vp mcp` — this starts a stdio MCP server. See your agent's docs for how to add a local MCP server.\";\n\nconst agents: Record<string, AgentConfig> = {\n  'claude-code': {\n    displayName: 'Claude Code',\n    skillsDir: '.claude/skills',\n    detect: (root) =>\n      fs.existsSync(path.join(root, '.claude')) || fs.existsSync(path.join(root, 'CLAUDE.md')),\n    mcpConfig: [\n      { filePath: '.claude/settings.json', rootKey: 'mcpServers' },\n      { filePath: '.claude/settings.local.json', rootKey: 'mcpServers' },\n    ],\n  },\n  amp: {\n    displayName: 'Amp',\n    skillsDir: '.agents/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.amp')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  codex: {\n    displayName: 'Codex',\n    skillsDir: '.agents/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.codex')),\n    mcpHint: 'codex mcp add vite-plus -- npx vp mcp',\n  },\n  cursor: {\n    displayName: 'Cursor',\n    skillsDir: '.agents/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.cursor')),\n    mcpConfig: [{ filePath: '.cursor/mcp.json', rootKey: 'mcpServers' }],\n  },\n  windsurf: {\n    displayName: 'Windsurf',\n    skillsDir: '.windsurf/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.windsurf')),\n    mcpConfig: [{ filePath: '.windsurf/mcp.json', rootKey: 'mcpServers' }],\n  },\n  'gemini-cli': {\n    displayName: 'Gemini CLI',\n    skillsDir: '.agents/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.gemini')),\n    mcpHint: 'gemini mcp add vite-plus -- npx vp mcp',\n  },\n  'github-copilot': {\n    displayName: 'GitHub Copilot',\n    skillsDir: '.agents/skills',\n    detect: (root) =>\n      fs.existsSync(path.join(root, '.github', 'copilot-instructions.md')) ||\n      fs.existsSync(path.join(root, '.vscode', 'mcp.json')),\n    mcpConfig: [\n      { filePath: '.vscode/mcp.json', rootKey: 'servers', extraFields: { type: 'stdio' } },\n    ],\n  },\n  cline: {\n    displayName: 'Cline',\n    skillsDir: '.cline/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.cline')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  roo: {\n    displayName: 'Roo Code',\n    skillsDir: '.roo/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.roo')),\n    mcpConfig: [{ filePath: '.roo/mcp.json', rootKey: 'mcpServers' }],\n  },\n  kilo: {\n    displayName: 'Kilo Code',\n    skillsDir: '.kilocode/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.kilocode')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  continue: {\n    displayName: 'Continue',\n    skillsDir: '.continue/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.continue')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  goose: {\n    displayName: 'Goose',\n    skillsDir: '.goose/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.goose')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  opencode: {\n    displayName: 'OpenCode',\n    skillsDir: '.agents/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.opencode')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  trae: {\n    displayName: 'Trae',\n    skillsDir: '.trae/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.trae')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  junie: {\n    displayName: 'Junie',\n    skillsDir: '.junie/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.junie')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  'kiro-cli': {\n    displayName: 'Kiro CLI',\n    skillsDir: '.kiro/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.kiro')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  zencoder: {\n    displayName: 'Zencoder',\n    skillsDir: '.zencoder/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.zencoder')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n  'qwen-code': {\n    displayName: 'Qwen Code',\n    skillsDir: '.qwen/skills',\n    detect: (root) => fs.existsSync(path.join(root, '.qwen')),\n    mcpHint: DEFAULT_MCP_HINT,\n  },\n};\n\n// --- Registry functions ---\n\nexport function getAgentById(id: string): AgentConfig | undefined {\n  return agents[id];\n}\n\nexport function detectAgents(root: string): AgentConfig[] {\n  return Object.values(agents).filter((a) => a.detect(root));\n}\n\n// --- Backward-compatible exports ---\n\nconst AGENT_ALIASES: Record<string, string> = {\n  chatgpt: 'chatgpt-codex',\n  codex: 'chatgpt-codex',\n};\n\nexport const AGENTS = [\n  { id: 'chatgpt-codex', label: 'ChatGPT (Codex)', targetPath: 'AGENTS.md' },\n  { id: 'claude', label: 'Claude Code', targetPath: 'CLAUDE.md' },\n  { id: 'gemini', label: 'Gemini CLI', targetPath: 'GEMINI.md' },\n  {\n    id: 'copilot',\n    label: 'GitHub Copilot',\n    targetPath: '.github/copilot-instructions.md',\n  },\n  { id: 'cursor', label: 'Cursor', targetPath: '.cursor/rules/viteplus.mdc' },\n  {\n    id: 'jetbrains',\n    label: 'JetBrains AI Assistant',\n    targetPath: '.aiassistant/rules/viteplus.md',\n  },\n  { id: 'amp', label: 'Amp', targetPath: 'AGENTS.md' },\n  { id: 'kiro', label: 'Kiro', targetPath: 'AGENTS.md' },\n  { id: 'opencode', label: 'OpenCode', targetPath: 'AGENTS.md' },\n  { id: 'other', label: 'Other', targetPath: 'AGENTS.md' },\n] as const;\n\ntype AgentSelection = string | string[] | false;\nconst AGENT_STANDARD_PATH = 'AGENTS.md';\nconst AGENT_INSTRUCTIONS_START_MARKER = '<!--VITE PLUS START-->';\nconst AGENT_INSTRUCTIONS_END_MARKER = '<!--VITE PLUS END-->';\n\nexport async function selectAgentTargetPaths({\n  interactive,\n  agent,\n  onCancel,\n}: {\n  interactive: boolean;\n  agent?: AgentSelection;\n  onCancel: () => void;\n}) {\n  // Skip entirely if --no-agent is passed\n  if (agent === false) {\n    return undefined;\n  }\n\n  if (interactive && !agent) {\n    const selectedAgents = await prompts.multiselect({\n      message: 'Which agents are you using?',\n      options: AGENTS.map((option) => ({\n        label: option.label,\n        value: option.id,\n        hint: option.targetPath,\n      })),\n      initialValues: ['chatgpt-codex'],\n      required: false,\n    });\n\n    if (prompts.isCancel(selectedAgents)) {\n      onCancel();\n      return undefined;\n    }\n\n    if (selectedAgents.length === 0) {\n      return undefined;\n    }\n    return resolveAgentTargetPaths(selectedAgents);\n  }\n\n  return resolveAgentTargetPaths(agent ?? 'other');\n}\n\nexport async function selectAgentTargetPath({\n  interactive,\n  agent,\n  onCancel,\n}: {\n  interactive: boolean;\n  agent?: AgentSelection;\n  onCancel: () => void;\n}) {\n  const targetPaths = await selectAgentTargetPaths({ interactive, agent, onCancel });\n  return targetPaths?.[0];\n}\n\nexport function detectExistingAgentTargetPaths(projectRoot: string) {\n  const detectedPaths: string[] = [];\n  const seenTargetPaths = new Set<string>();\n  for (const option of AGENTS) {\n    if (seenTargetPaths.has(option.targetPath)) {\n      continue;\n    }\n    seenTargetPaths.add(option.targetPath);\n    const targetPath = path.join(projectRoot, option.targetPath);\n    if (fs.existsSync(targetPath) && !fs.lstatSync(targetPath).isSymbolicLink()) {\n      detectedPaths.push(option.targetPath);\n    }\n  }\n  return detectedPaths.length > 0 ? detectedPaths : undefined;\n}\n\nexport function detectExistingAgentTargetPath(projectRoot: string) {\n  return detectExistingAgentTargetPaths(projectRoot)?.[0];\n}\n\nexport function hasExistingAgentInstructions(projectRoot: string): boolean {\n  const targetPaths = detectExistingAgentTargetPaths(projectRoot);\n  if (!targetPaths) {\n    return false;\n  }\n  for (const targetPath of targetPaths) {\n    const content = fs.readFileSync(path.join(projectRoot, targetPath), 'utf-8');\n    if (content.includes(AGENT_INSTRUCTIONS_START_MARKER)) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * Silently update agent instruction files that contain Vite+ markers.\n * - No agent files → no writes\n * - No Vite+ markers → no writes\n * - Markers present, content up to date → no writes\n * - Markers present, content outdated → update marked section\n */\nexport function updateExistingAgentInstructions(projectRoot: string): void {\n  const targetPaths = detectExistingAgentTargetPaths(projectRoot);\n  if (!targetPaths) {\n    return;\n  }\n\n  const templatePath = path.join(pkgRoot, 'AGENTS.md');\n  if (!fs.existsSync(templatePath)) {\n    return;\n  }\n\n  const templateContent = fs.readFileSync(templatePath, 'utf-8');\n\n  for (const targetPath of targetPaths) {\n    try {\n      const fullPath = path.join(projectRoot, targetPath);\n      const existing = fs.readFileSync(fullPath, 'utf-8');\n      const updated = replaceMarkedAgentInstructionsSection(existing, templateContent);\n      if (updated !== undefined && updated !== existing) {\n        fs.writeFileSync(fullPath, updated);\n      }\n    } catch {\n      // Best-effort: skip files that can't be read or written\n    }\n  }\n}\n\nexport function resolveAgentTargetPaths(agent?: string | string[]) {\n  const agentNames = parseAgentNames(agent);\n  const resolvedAgentNames = agentNames.length > 0 ? agentNames : ['other'];\n  const dedupedTargetPaths: string[] = [];\n  const seenTargetPaths = new Set<string>();\n  for (const name of resolvedAgentNames) {\n    const targetPath = resolveSingleAgentTargetPath(name);\n    if (seenTargetPaths.has(targetPath)) {\n      continue;\n    }\n    seenTargetPaths.add(targetPath);\n    dedupedTargetPaths.push(targetPath);\n  }\n  return dedupedTargetPaths;\n}\n\nexport function resolveAgentTargetPath(agent?: string) {\n  return resolveAgentTargetPaths(agent)[0] ?? 'AGENTS.md';\n}\n\nfunction parseAgentNames(agent?: string | string[]) {\n  if (!agent) {\n    return [];\n  }\n  const values = Array.isArray(agent) ? agent : [agent];\n  return values\n    .filter((value): value is string => typeof value === 'string')\n    .flatMap((value) => value.split(','))\n    .map((value) => value.trim())\n    .filter((value) => value.length > 0);\n}\n\nfunction resolveSingleAgentTargetPath(agent: string) {\n  const normalized = normalizeAgentName(agent);\n  const alias = AGENT_ALIASES[normalized];\n  const resolved = alias ? normalizeAgentName(alias) : normalized;\n  const match = AGENTS.find(\n    (option) =>\n      normalizeAgentName(option.id) === resolved || normalizeAgentName(option.label) === resolved,\n  );\n  return match?.targetPath ?? AGENTS[AGENTS.length - 1].targetPath;\n}\n\nexport interface AgentConflictInfo {\n  targetPath: string;\n}\n\n/**\n * Detect agent instruction files that would conflict (exist without markers).\n * Returns only files that need a user decision (append or skip).\n * Read-only — does not write or modify any files.\n */\nexport async function detectAgentConflicts({\n  projectRoot,\n  targetPaths,\n}: {\n  projectRoot: string;\n  targetPaths?: string[];\n}): Promise<AgentConflictInfo[]> {\n  if (!targetPaths || targetPaths.length === 0) {\n    return [];\n  }\n\n  const sourcePath = path.join(pkgRoot, 'AGENTS.md');\n  if (!fs.existsSync(sourcePath)) {\n    return [];\n  }\n\n  const incomingContent = await fsPromises.readFile(sourcePath, 'utf-8');\n  const shouldLinkToAgents = targetPaths.includes(AGENT_STANDARD_PATH);\n  const orderedPaths = shouldLinkToAgents\n    ? [AGENT_STANDARD_PATH, ...targetPaths.filter((p) => p !== AGENT_STANDARD_PATH)]\n    : targetPaths;\n\n  const conflicts: AgentConflictInfo[] = [];\n  const seenDestinationPaths = new Set<string>();\n  const seenRealPaths = new Set<string>();\n\n  for (const targetPathToCheck of orderedPaths) {\n    const destinationPath = path.join(projectRoot, targetPathToCheck);\n    const destinationKey = path.resolve(destinationPath);\n    if (seenDestinationPaths.has(destinationKey)) {\n      continue;\n    }\n    seenDestinationPaths.add(destinationKey);\n\n    // If linking to AGENTS.md, non-AGENTS.md paths that are not regular files get linked\n    if (shouldLinkToAgents && targetPathToCheck !== AGENT_STANDARD_PATH) {\n      const existing = await getExistingPathKind(destinationPath);\n      if (existing !== 'file') {\n        continue;\n      }\n    }\n\n    if (fs.existsSync(destinationPath)) {\n      if (fs.lstatSync(destinationPath).isSymbolicLink()) {\n        continue;\n      }\n\n      const destinationRealPath = await fsPromises.realpath(destinationPath);\n      if (seenRealPaths.has(destinationRealPath)) {\n        continue;\n      }\n\n      const existingContent = await fsPromises.readFile(destinationPath, 'utf-8');\n      const updatedContent = replaceMarkedAgentInstructionsSection(\n        existingContent,\n        incomingContent,\n      );\n      if (updatedContent !== undefined) {\n        // Has markers — will auto-update, no conflict\n        seenRealPaths.add(destinationRealPath);\n        continue;\n      }\n\n      // Conflict — needs user decision\n      conflicts.push({ targetPath: targetPathToCheck });\n      seenRealPaths.add(destinationRealPath);\n    }\n  }\n\n  return conflicts;\n}\n\nexport async function writeAgentInstructions({\n  projectRoot,\n  targetPath,\n  targetPaths,\n  interactive,\n  conflictDecisions,\n  silent = false,\n}: {\n  projectRoot: string;\n  targetPath?: string;\n  targetPaths?: string[];\n  interactive: boolean;\n  conflictDecisions?: Map<string, 'append' | 'skip'>;\n  silent?: boolean;\n}) {\n  const paths = [...(targetPaths ?? []), ...(targetPath ? [targetPath] : [])];\n  if (paths.length === 0) {\n    return;\n  }\n\n  const sourcePath = path.join(pkgRoot, 'AGENTS.md');\n  if (!fs.existsSync(sourcePath)) {\n    if (!silent) {\n      prompts.log.warn('Agent instructions template not found; skipping.');\n    }\n    return;\n  }\n\n  const seenDestinationPaths = new Set<string>();\n  const seenRealPaths = new Set<string>();\n  const incomingContent = await fsPromises.readFile(sourcePath, 'utf-8');\n  const shouldLinkToAgents = paths.includes(AGENT_STANDARD_PATH);\n  const orderedPaths = shouldLinkToAgents\n    ? [AGENT_STANDARD_PATH, ...paths.filter((p) => p !== AGENT_STANDARD_PATH)]\n    : paths;\n\n  for (const targetPathToWrite of orderedPaths) {\n    const destinationPath = path.join(projectRoot, targetPathToWrite);\n    const destinationKey = path.resolve(destinationPath);\n    if (seenDestinationPaths.has(destinationKey)) {\n      continue;\n    }\n    seenDestinationPaths.add(destinationKey);\n\n    await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true });\n\n    if (shouldLinkToAgents && targetPathToWrite !== AGENT_STANDARD_PATH) {\n      const linked = await tryLinkTargetToAgents(projectRoot, targetPathToWrite, silent);\n      if (linked) {\n        continue;\n      }\n    }\n\n    if (fs.existsSync(destinationPath)) {\n      if (fs.lstatSync(destinationPath).isSymbolicLink()) {\n        if (!silent) {\n          prompts.log.info(`Skipped writing ${targetPathToWrite} (symlink)`);\n        }\n        continue;\n      }\n\n      const destinationRealPath = await fsPromises.realpath(destinationPath);\n      if (seenRealPaths.has(destinationRealPath)) {\n        if (!silent) {\n          prompts.log.info(`Skipped writing ${targetPathToWrite} (duplicate target)`);\n        }\n        continue;\n      }\n\n      const existingContent = await fsPromises.readFile(destinationPath, 'utf-8');\n      const updatedContent = replaceMarkedAgentInstructionsSection(\n        existingContent,\n        incomingContent,\n      );\n      if (updatedContent !== undefined) {\n        if (updatedContent !== existingContent) {\n          await fsPromises.writeFile(destinationPath, updatedContent);\n        }\n        seenRealPaths.add(destinationRealPath);\n        continue;\n      }\n\n      // Determine conflict action from pre-resolved decisions, interactive prompt, or default\n      let conflictAction: 'append' | 'skip';\n      const preResolved = conflictDecisions?.get(targetPathToWrite);\n      if (preResolved) {\n        conflictAction = preResolved;\n      } else if (interactive) {\n        const action = await prompts.select({\n          message: `Agent instructions already exist at ${targetPathToWrite}.`,\n          options: [\n            {\n              label: 'Append',\n              value: 'append',\n              hint: 'Add template content to the end',\n            },\n            {\n              label: 'Skip',\n              value: 'skip',\n              hint: 'Leave existing file unchanged',\n            },\n          ],\n          initialValue: 'skip',\n        });\n        conflictAction = prompts.isCancel(action) || action === 'skip' ? 'skip' : 'append';\n      } else {\n        conflictAction = 'skip';\n      }\n\n      if (conflictAction === 'append') {\n        await appendAgentContent(\n          destinationPath,\n          targetPathToWrite,\n          existingContent,\n          incomingContent,\n          silent,\n        );\n      } else {\n        const suffix = !preResolved && !interactive ? ' (already exists)' : '';\n        if (!silent) {\n          prompts.log.info(`Skipped writing ${targetPathToWrite}${suffix}`);\n        }\n      }\n      seenRealPaths.add(destinationRealPath);\n      continue;\n    }\n\n    await fsPromises.writeFile(destinationPath, incomingContent);\n    if (!silent) {\n      prompts.log.success(`Wrote agent instructions to ${targetPathToWrite}`);\n    }\n    seenRealPaths.add(await fsPromises.realpath(destinationPath));\n  }\n}\n\nasync function appendAgentContent(\n  destinationPath: string,\n  targetPath: string,\n  existingContent: string,\n  incomingContent: string,\n  silent = false,\n) {\n  const separator = existingContent.endsWith('\\n') ? '' : '\\n';\n  await fsPromises.appendFile(destinationPath, `${separator}\\n${incomingContent}`);\n  if (!silent) {\n    prompts.log.success(`Appended agent instructions to ${targetPath}`);\n  }\n}\n\nfunction normalizeAgentName(value: string) {\n  return value\n    .trim()\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, '');\n}\n\nexport function replaceMarkedAgentInstructionsSection(existing: string, incoming: string) {\n  const existingRange = getMarkedRange(\n    existing,\n    AGENT_INSTRUCTIONS_START_MARKER,\n    AGENT_INSTRUCTIONS_END_MARKER,\n  );\n  if (!existingRange) {\n    return undefined;\n  }\n\n  const incomingRange = getMarkedRange(\n    incoming,\n    AGENT_INSTRUCTIONS_START_MARKER,\n    AGENT_INSTRUCTIONS_END_MARKER,\n  );\n  if (!incomingRange) {\n    return undefined;\n  }\n\n  return `${existing.slice(0, existingRange.start)}${incoming.slice(\n    incomingRange.start,\n    incomingRange.end,\n  )}${existing.slice(existingRange.end)}`;\n}\n\nasync function tryLinkTargetToAgents(projectRoot: string, targetPath: string, silent = false) {\n  const destinationPath = path.join(projectRoot, targetPath);\n  const agentsPath = path.join(projectRoot, AGENT_STANDARD_PATH);\n  const symlinkTarget = path.relative(path.dirname(destinationPath), agentsPath);\n  const existing = await getExistingPathKind(destinationPath);\n\n  if (existing === 'file') {\n    return false;\n  }\n\n  if (existing === 'symlink') {\n    const currentLink = await fsPromises.readlink(destinationPath);\n    const resolvedCurrentLink = path.resolve(path.dirname(destinationPath), currentLink);\n    if (resolvedCurrentLink === agentsPath) {\n      if (!silent) {\n        prompts.log.info(\n          `Skipped linking ${targetPath} (already linked to ${AGENT_STANDARD_PATH})`,\n        );\n      }\n      return true;\n    }\n    await fsPromises.unlink(destinationPath);\n  }\n\n  try {\n    await fsPromises.symlink(symlinkTarget, destinationPath);\n  } catch (err: unknown) {\n    if ((err as NodeJS.ErrnoException).code === 'EPERM') {\n      // On Windows, symlinks require admin privileges.\n      // Fall back to copying the file instead.\n      await fsPromises.copyFile(agentsPath, destinationPath);\n      if (!silent) {\n        prompts.log.success(`Copied ${AGENT_STANDARD_PATH} to ${targetPath}`);\n      }\n      return true;\n    }\n    throw err;\n  }\n  if (!silent) {\n    prompts.log.success(`Linked ${targetPath} to ${AGENT_STANDARD_PATH}`);\n  }\n  return true;\n}\n\nasync function getExistingPathKind(filePath: string) {\n  if (!fs.existsSync(filePath)) {\n    return 'missing' as const;\n  }\n  const stat = await fsPromises.lstat(filePath);\n  return stat.isSymbolicLink() ? ('symlink' as const) : ('file' as const);\n}\n\nfunction getMarkedRange(content: string, startMarker: string, endMarker: string) {\n  const start = content.indexOf(startMarker);\n  if (start === -1) {\n    return undefined;\n  }\n  const endMarkerIndex = content.indexOf(endMarker, start + startMarker.length);\n  if (endMarkerIndex === -1) {\n    return undefined;\n  }\n  return {\n    start,\n    end: endMarkerIndex + endMarker.length,\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/utils/command.ts",
    "content": "import spawn from 'cross-spawn';\n\nexport interface RunCommandOptions {\n  command: string;\n  args: string[];\n  cwd: string;\n  envs: NodeJS.ProcessEnv;\n}\n\nexport interface RunCommandResult {\n  exitCode: number;\n  stdout: Buffer;\n  stderr: Buffer;\n}\n\nexport async function runCommandSilently(options: RunCommandOptions): Promise<RunCommandResult> {\n  const child = spawn(options.command, options.args, {\n    stdio: 'pipe',\n    cwd: options.cwd,\n    env: options.envs,\n  });\n  const promise = new Promise<RunCommandResult>((resolve, reject) => {\n    const stdout: Buffer[] = [];\n    const stderr: Buffer[] = [];\n    child.stdout?.on('data', (data) => {\n      stdout.push(data);\n    });\n    child.stderr?.on('data', (data) => {\n      stderr.push(data);\n    });\n    child.on('close', (code) => {\n      resolve({\n        exitCode: code ?? 0,\n        stdout: Buffer.concat(stdout),\n        stderr: Buffer.concat(stderr),\n      });\n    });\n    child.on('error', (err) => {\n      reject(err);\n    });\n  });\n  return await promise;\n}\n\nexport async function runCommand(options: RunCommandOptions): Promise<number> {\n  const child = spawn(options.command, options.args, {\n    stdio: 'inherit',\n    cwd: options.cwd,\n    env: options.envs,\n  });\n  return new Promise<number>((resolve, reject) => {\n    child.on('close', (code) => {\n      resolve(code ?? 0);\n    });\n    child.on('error', (err) => {\n      reject(err);\n    });\n  });\n}\n"
  },
  {
    "path": "packages/cli/src/utils/constants.ts",
    "content": "import { createRequire } from 'node:module';\n\nexport const VITE_PLUS_NAME = 'vite-plus';\nexport const VITE_PLUS_VERSION = process.env.VITE_PLUS_VERSION || 'latest';\n\nexport const VITE_PLUS_OVERRIDE_PACKAGES: Record<string, string> = process.env\n  .VITE_PLUS_OVERRIDE_PACKAGES\n  ? JSON.parse(process.env.VITE_PLUS_OVERRIDE_PACKAGES)\n  : {\n      vite: 'npm:@voidzero-dev/vite-plus-core@latest',\n      vitest: 'npm:@voidzero-dev/vite-plus-test@latest',\n    };\n\n/**\n * When VITE_PLUS_FORCE_MIGRATE is set, force full dependency rewriting\n * even for projects already using vite-plus. Used by ecosystem CI to\n * override dependencies with locally built tgz packages.\n */\nexport function isForceOverrideMode(): boolean {\n  return process.env.VITE_PLUS_FORCE_MIGRATE === '1';\n}\n\nconst require = createRequire(import.meta.url);\n\nexport function resolve(path: string) {\n  return require.resolve(path, {\n    paths: [process.cwd(), import.meta.dirname],\n  });\n}\n\nexport const BASEURL_TSCONFIG_WARNING =\n  'Skipped typeAware/typeCheck: tsconfig.json contains baseUrl which is not yet supported by the oxlint type checker.\\n' +\n  '  Run `npx @andrewbranch/ts5to6 --fixBaseUrl .` to remove baseUrl from your tsconfig.';\n\nexport const DEFAULT_ENVS = {\n  // Provide Node.js runtime information for oxfmt's telemetry/compatibility\n  JS_RUNTIME_VERSION: process.versions.node,\n  JS_RUNTIME_NAME: process.release.name,\n  // Indicate that vite-plus is the package manager\n  NODE_PACKAGE_MANAGER: 'vite-plus',\n} as const;\n"
  },
  {
    "path": "packages/cli/src/utils/editor.ts",
    "content": "import fs from 'node:fs';\nimport fsPromises from 'node:fs/promises';\nimport path from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\n\nimport { readJsonFile, writeJsonFile } from './json.js';\n\nconst VSCODE_SETTINGS = {\n  // Set as default over per-lang to avoid conflicts with other formatters\n  'editor.defaultFormatter': 'oxc.oxc-vscode',\n  'oxc.fmt.configPath': './vite.config.ts',\n  'editor.formatOnSave': true,\n  // Oxfmt does not support partial formatting\n  'editor.formatOnSaveMode': 'file',\n  'editor.codeActionsOnSave': {\n    'source.fixAll.oxc': 'explicit',\n  },\n} as const;\n\nconst VSCODE_EXTENSIONS = {\n  recommendations: ['VoidZero.vite-plus-extension-pack'],\n} as const;\n\nconst ZED_SETTINGS = {\n  lsp: {\n    oxlint: {\n      initialization_options: {\n        settings: {\n          configPath: './.oxlintrc.json',\n          run: 'onType',\n          disableNestedConfig: false,\n          fixKind: 'safe_fix',\n          typeAware: true,\n          unusedDisableDirectives: 'deny',\n        },\n      },\n    },\n    oxfmt: {\n      initialization_options: {\n        settings: {\n          configPath: './vite.config.ts',\n          run: 'onSave',\n        },\n      },\n    },\n  },\n  languages: {\n    CSS: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    GraphQL: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    Handlebars: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    HTML: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    JavaScript: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n      code_action: 'source.fixAll.oxc',\n    },\n    JSX: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    JSON: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    JSON5: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    JSONC: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    Less: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    Markdown: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    MDX: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    SCSS: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    TypeScript: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    TSX: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    'Vue.js': {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n    YAML: {\n      format_on_save: 'on',\n      prettier: { allowed: false },\n      formatter: [{ language_server: { name: 'oxfmt' } }],\n    },\n  },\n} as const;\n\nexport const EDITORS = [\n  {\n    id: 'vscode',\n    label: 'VSCode',\n    targetDir: '.vscode',\n    files: {\n      'settings.json': VSCODE_SETTINGS as Record<string, unknown>,\n      'extensions.json': VSCODE_EXTENSIONS as Record<string, unknown>,\n    },\n  },\n  {\n    id: 'zed',\n    label: 'Zed',\n    targetDir: '.zed',\n    files: {\n      'settings.json': ZED_SETTINGS as Record<string, unknown>,\n    },\n  },\n] as const;\n\nexport type EditorId = (typeof EDITORS)[number]['id'];\n\nexport async function selectEditor({\n  interactive,\n  editor,\n  onCancel,\n}: {\n  interactive: boolean;\n  editor?: string | false;\n  onCancel: () => void;\n}): Promise<EditorId | undefined> {\n  // Skip entirely if --no-editor is passed\n  if (editor === false) {\n    return undefined;\n  }\n\n  if (interactive && !editor) {\n    const editorOptions = EDITORS.map((option) => ({\n      label: option.label,\n      value: option.id,\n      hint: option.targetDir,\n    }));\n    const noneOption = {\n      label: 'None',\n      value: null,\n      hint: 'Skip writing editor configs',\n    };\n    const selectedEditor = await prompts.select({\n      message: 'Which editor are you using?',\n      options: [...editorOptions, noneOption],\n      initialValue: 'vscode',\n    });\n\n    if (prompts.isCancel(selectedEditor)) {\n      onCancel();\n      return undefined;\n    }\n\n    if (selectedEditor === null) {\n      return undefined;\n    }\n    return resolveEditorId(selectedEditor);\n  }\n\n  if (editor) {\n    return resolveEditorId(editor);\n  }\n\n  return undefined;\n}\n\nexport function detectExistingEditor(projectRoot: string): EditorId | undefined {\n  for (const option of EDITORS) {\n    for (const fileName of Object.keys(option.files)) {\n      const filePath = path.join(projectRoot, option.targetDir, fileName);\n      if (fs.existsSync(filePath)) {\n        return option.id;\n      }\n    }\n  }\n  return undefined;\n}\n\nexport interface EditorConflictInfo {\n  fileName: string;\n  displayPath: string;\n}\n\n/**\n * Detect editor config files that would conflict (already exist).\n * Read-only — does not write or modify any files.\n */\nexport function detectEditorConflicts({\n  projectRoot,\n  editorId,\n}: {\n  projectRoot: string;\n  editorId: EditorId | undefined;\n}): EditorConflictInfo[] {\n  if (!editorId) {\n    return [];\n  }\n\n  const editorConfig = EDITORS.find((e) => e.id === editorId);\n  if (!editorConfig) {\n    return [];\n  }\n\n  const conflicts: EditorConflictInfo[] = [];\n  for (const fileName of Object.keys(editorConfig.files)) {\n    const filePath = path.join(projectRoot, editorConfig.targetDir, fileName);\n    if (fs.existsSync(filePath)) {\n      conflicts.push({\n        fileName,\n        displayPath: `${editorConfig.targetDir}/${fileName}`,\n      });\n    }\n  }\n\n  return conflicts;\n}\n\nexport async function writeEditorConfigs({\n  projectRoot,\n  editorId,\n  interactive,\n  conflictDecisions,\n  silent = false,\n}: {\n  projectRoot: string;\n  editorId: EditorId | undefined;\n  interactive: boolean;\n  conflictDecisions?: Map<string, 'merge' | 'skip'>;\n  silent?: boolean;\n}) {\n  if (!editorId) {\n    return;\n  }\n\n  const editorConfig = EDITORS.find((e) => e.id === editorId);\n  if (!editorConfig) {\n    return;\n  }\n\n  const targetDir = path.join(projectRoot, editorConfig.targetDir);\n  await fsPromises.mkdir(targetDir, { recursive: true });\n\n  for (const [fileName, incoming] of Object.entries(editorConfig.files)) {\n    const filePath = path.join(targetDir, fileName);\n\n    if (fs.existsSync(filePath)) {\n      const displayPath = `${editorConfig.targetDir}/${fileName}`;\n\n      // Determine conflict action from pre-resolved decisions, interactive prompt, or default\n      let conflictAction: 'merge' | 'skip';\n      const preResolved = conflictDecisions?.get(fileName);\n      if (preResolved) {\n        conflictAction = preResolved;\n      } else if (interactive) {\n        const action = await prompts.select({\n          message: `${displayPath} already exists.`,\n          options: [\n            {\n              label: 'Merge',\n              value: 'merge',\n              hint: 'Merge new settings into existing file',\n            },\n            {\n              label: 'Skip',\n              value: 'skip',\n              hint: 'Leave existing file unchanged',\n            },\n          ],\n          initialValue: 'skip',\n        });\n        conflictAction = prompts.isCancel(action) || action === 'skip' ? 'skip' : 'merge';\n      } else {\n        // Non-interactive: always merge (safe because existing keys are never overwritten)\n        conflictAction = 'merge';\n      }\n\n      if (conflictAction === 'merge') {\n        mergeAndWriteEditorConfig(filePath, incoming, fileName, displayPath, silent);\n      } else {\n        if (!silent) {\n          prompts.log.info(`Skipped writing ${displayPath}`);\n        }\n      }\n      continue;\n    }\n\n    writeJsonFile(filePath, incoming);\n    if (!silent) {\n      prompts.log.success(`Wrote editor config to ${editorConfig.targetDir}/${fileName}`);\n    }\n  }\n}\n\nfunction mergeAndWriteEditorConfig(\n  filePath: string,\n  incoming: Record<string, unknown>,\n  fileName: string,\n  displayPath: string,\n  silent = false,\n) {\n  const existing = readJsonFile(filePath, true);\n  const merged = mergeEditorConfigs(existing, incoming, fileName);\n  writeJsonFile(filePath, merged);\n  if (!silent) {\n    prompts.log.success(`Merged editor config into ${displayPath}`);\n  }\n}\n\nfunction mergeEditorConfigs(\n  existing: Record<string, unknown>,\n  incoming: Record<string, unknown>,\n  fileName: string,\n): Record<string, unknown> {\n  if (fileName === 'extensions.json') {\n    const existingRecs = Array.isArray(existing['recommendations'])\n      ? (existing['recommendations'] as string[])\n      : [];\n    const incomingRecs = Array.isArray(incoming['recommendations'])\n      ? (incoming['recommendations'] as string[])\n      : [];\n    return {\n      ...existing,\n      recommendations: [...new Set([...existingRecs, ...incomingRecs])],\n    };\n  }\n\n  return deepMerge(existing, incoming);\n}\n\nfunction deepMerge(\n  target: Record<string, unknown>,\n  source: Record<string, unknown>,\n): Record<string, unknown> {\n  const result = { ...target };\n  for (const [key, value] of Object.entries(source)) {\n    if (!(key in result)) {\n      result[key] = value;\n    } else if (\n      typeof result[key] === 'object' &&\n      result[key] !== null &&\n      !Array.isArray(result[key]) &&\n      typeof value === 'object' &&\n      value !== null &&\n      !Array.isArray(value)\n    ) {\n      result[key] = deepMerge(\n        result[key] as Record<string, unknown>,\n        value as Record<string, unknown>,\n      );\n    }\n  }\n  return result;\n}\n\nfunction resolveEditorId(editor: string): EditorId | undefined {\n  const normalized = editor.trim().toLowerCase();\n  const match = EDITORS.find(\n    (option) => option.id === normalized || option.label.toLowerCase() === normalized,\n  );\n  return match?.id;\n}\n"
  },
  {
    "path": "packages/cli/src/utils/help.ts",
    "content": "import { stripVTControlCharacters, styleText } from 'node:util';\n\nexport type CliDoc = {\n  usage?: string;\n  summary?: readonly string[] | string;\n  sections: readonly CliSection[];\n  documentationUrl?: string;\n};\n\nexport type CliSection = {\n  title: string;\n  lines?: readonly string[] | string;\n  rows?: readonly CliRow[];\n};\n\nexport type CliRow = {\n  label: string;\n  description: readonly string[] | string;\n};\n\nexport type RenderCliDocOptions = {\n  color?: boolean;\n};\n\nfunction toLines(value?: readonly string[] | string): string[] {\n  if (!value) {\n    return [];\n  }\n\n  return Array.isArray(value) ? [...value] : [value as string];\n}\n\nfunction visibleLength(value: string): number {\n  return stripVTControlCharacters(value).length;\n}\n\nfunction padVisible(value: string, width: number): string {\n  const padding = Math.max(0, width - visibleLength(value));\n  return `${value}${' '.repeat(padding)}`;\n}\n\nfunction renderRows(rows: readonly CliRow[]): string[] {\n  if (rows.length === 0) {\n    return [];\n  }\n\n  const labelWidth = Math.max(...rows.map((row) => visibleLength(row.label)));\n  const output: string[] = [];\n\n  for (const row of rows) {\n    const descriptionLines = toLines(row.description);\n\n    if (descriptionLines.length === 0) {\n      output.push(`  ${row.label}`);\n      continue;\n    }\n\n    const [firstLine, ...rest] = descriptionLines;\n    output.push(`  ${padVisible(row.label, labelWidth)}  ${firstLine}`);\n\n    for (const line of rest) {\n      output.push(`  ${' '.repeat(labelWidth)}  ${line}`);\n    }\n  }\n\n  return output;\n}\n\nfunction heading(label: string, color: boolean): string {\n  if (!color) {\n    return `${label}:`;\n  }\n\n  return label === 'Usage'\n    ? styleText('bold', `${label}:`)\n    : styleText(['blue', 'bold'], `${label}:`);\n}\n\nexport function renderCliDoc(doc: CliDoc, options: RenderCliDocOptions = {}): string {\n  const color = options.color ?? true;\n  const output: string[] = [];\n\n  if (doc.usage) {\n    const usage = color ? styleText('bold', doc.usage) : doc.usage;\n    output.push(`${heading('Usage', color)} ${usage}`);\n  }\n\n  const summaryLines = toLines(doc.summary);\n  if (summaryLines.length > 0) {\n    if (output.length > 0) {\n      output.push('');\n    }\n    output.push(...summaryLines);\n  }\n\n  for (const section of doc.sections) {\n    if (output.length > 0) {\n      output.push('');\n    }\n    output.push(heading(section.title, color));\n\n    const lines = toLines(section.lines);\n    if (lines.length > 0) {\n      output.push(...lines);\n    }\n\n    if (section.rows && section.rows.length > 0) {\n      output.push(...renderRows(section.rows));\n    }\n  }\n\n  if (doc.documentationUrl) {\n    if (output.length > 0) {\n      output.push('');\n    }\n    output.push(`${heading('Documentation', color)} ${doc.documentationUrl}`);\n  }\n\n  output.push('');\n  return output.join('\\n');\n}\n"
  },
  {
    "path": "packages/cli/src/utils/json.ts",
    "content": "import fs from 'node:fs';\n\nimport detectIndent from 'detect-indent';\nimport { detectNewline } from 'detect-newline';\nimport { parse as parseJsonc } from 'jsonc-parser';\n\nexport function readJsonFile<T = Record<string, unknown>>(\n  file: string,\n  allowComments?: boolean,\n): T {\n  const content = fs.readFileSync(file, 'utf-8');\n  const parseFunction = allowComments ? parseJsonc : JSON.parse;\n  return parseFunction(content) as T;\n}\n\nexport function writeJsonFile<T = Record<string, unknown>>(file: string, data: T) {\n  let newline = '\\n';\n  let indent = '  ';\n  if (fs.existsSync(file)) {\n    const content = fs.readFileSync(file, 'utf-8');\n    // keep the original newline and indent\n    indent = detectIndent(content).indent;\n    newline = detectNewline(content) ?? '';\n  }\n  fs.writeFileSync(file, JSON.stringify(data, null, indent) + newline, 'utf-8');\n}\n\nexport function editJsonFile<T = Record<string, unknown>>(\n  file: string,\n  callback: (content: T) => T | undefined,\n) {\n  const json = readJsonFile<T>(file);\n  const newJson = callback(json);\n  if (newJson) {\n    writeJsonFile(file, newJson);\n  }\n}\n\nexport function isJsonFile(file: string): boolean {\n  try {\n    readJsonFile(file);\n    return true;\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/utils/package.ts",
    "content": "import fs from 'node:fs';\nimport { createRequire } from 'node:module';\nimport path from 'node:path';\n\nimport { VITE_PLUS_NAME } from './constants.js';\nimport { readJsonFile } from './json.js';\n\nexport function getScopeFromPackageName(packageName: string): string {\n  if (packageName.startsWith('@')) {\n    return packageName.split('/')[0];\n  }\n  return '';\n}\n\ninterface PackageMetadata {\n  name: string;\n  version: string;\n  path: string;\n}\n\nexport function detectPackageMetadata(\n  projectPath: string,\n  packageName: string,\n): PackageMetadata | void {\n  try {\n    // Create require from the project path so resolution only searches\n    // the project's node_modules, not the global installation's\n    const require = createRequire(path.join(projectPath, 'noop.js'));\n    const pkgFilePath = require.resolve(`${packageName}/package.json`);\n    const pkg = JSON.parse(fs.readFileSync(pkgFilePath, 'utf8'));\n    return {\n      name: pkg.name,\n      version: pkg.version,\n      path: path.dirname(pkgFilePath),\n    };\n  } catch {\n    // ignore MODULE_NOT_FOUND error\n    return;\n  }\n}\n\n/**\n * Read the nearest package.json file from the current directory up to the root directory.\n * @param currentDir - The current directory to start searching from.\n * @returns The package.json content as a JSON object, or null if no package.json is found.\n */\nexport function readNearestPackageJson<T = Record<string, unknown>>(currentDir: string): T | null {\n  do {\n    const packageJsonPath = path.join(currentDir, 'package.json');\n    if (fs.existsSync(packageJsonPath)) {\n      return readJsonFile<T>(packageJsonPath);\n    }\n    currentDir = path.dirname(currentDir);\n  } while (currentDir !== path.dirname(currentDir));\n  return null;\n}\n\nexport function hasVitePlusDependency(\n  pkg?: {\n    dependencies?: Record<string, string>;\n    devDependencies?: Record<string, string>;\n  } | null,\n) {\n  return Boolean(pkg?.dependencies?.[VITE_PLUS_NAME] || pkg?.devDependencies?.[VITE_PLUS_NAME]);\n}\n\n/**\n * Check if an npm package exists in the public registry.\n * Returns true if the package exists or if the check could not be performed (network error, timeout).\n * Returns false only if the registry definitively responds with 404.\n */\nexport async function checkNpmPackageExists(packageName: string): Promise<boolean> {\n  const atIndex = packageName.indexOf('@', 2);\n  const name = atIndex === -1 ? packageName : packageName.slice(0, atIndex);\n  try {\n    const response = await fetch(`https://registry.npmjs.org/${name}`, {\n      method: 'HEAD',\n      signal: AbortSignal.timeout(3000),\n    });\n    return response.status !== 404;\n  } catch {\n    return true; // Network error or timeout - let the package manager handle it\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/utils/path.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\n// Get the package root directory (packages/cli)\n// Works from both source (src/utils/) and bundled (dist/global/) locations\nfunction findPkgRoot(): string {\n  let dir = import.meta.dirname;\n  while (dir !== path.dirname(dir)) {\n    if (fs.existsSync(path.join(dir, 'package.json'))) {\n      return dir;\n    }\n    dir = path.dirname(dir);\n  }\n  return dir;\n}\n\nexport const pkgRoot = findPkgRoot();\n\nexport const templatesDir = path.join(pkgRoot, 'templates');\nexport const rulesDir = path.join(pkgRoot, 'rules');\n\nexport function displayRelative(to: string, from = process.cwd()): string {\n  return path.relative(from, to).replaceAll('\\\\', '/');\n}\n"
  },
  {
    "path": "packages/cli/src/utils/prompts.ts",
    "content": "import * as prompts from '@voidzero-dev/vite-plus-prompts';\n\nimport { downloadPackageManager as downloadPackageManagerBinding } from '../../binding/index.js';\nimport { PackageManager } from '../types/index.js';\nimport { runCommandSilently } from './command.js';\nimport { accent } from './terminal.js';\n\nexport interface CommandRunSummary {\n  durationMs: number;\n  exitCode?: number;\n  status: 'installed' | 'formatted' | 'failed' | 'skipped';\n}\n\nexport function cancelAndExit(message = 'Operation cancelled', exitCode = 0): never {\n  prompts.cancel(message);\n  process.exit(exitCode);\n}\n\nexport async function selectPackageManager(interactive?: boolean, silent = false) {\n  if (interactive) {\n    const selected = await prompts.select({\n      message: 'Which package manager would you like to use?',\n      options: [\n        { value: PackageManager.pnpm, hint: 'recommended' },\n        { value: PackageManager.yarn },\n        { value: PackageManager.npm },\n      ],\n      initialValue: PackageManager.pnpm,\n    });\n\n    if (prompts.isCancel(selected)) {\n      cancelAndExit();\n    }\n\n    return selected;\n  } else {\n    // --no-interactive: use pnpm as default\n    if (!silent) {\n      prompts.log.info(`Using default package manager: ${accent(PackageManager.pnpm)}`);\n    }\n    return PackageManager.pnpm;\n  }\n}\n\nexport async function downloadPackageManager(\n  packageManager: PackageManager,\n  version: string,\n  interactive?: boolean,\n  silent = false,\n) {\n  const spinner = silent ? getSilentSpinner() : getSpinner(interactive);\n  spinner.start(`${packageManager}@${version} installing...`);\n  const downloadResult = await downloadPackageManagerBinding({\n    name: packageManager,\n    version,\n  });\n  spinner.stop(`${packageManager}@${downloadResult.version} installed`);\n  return downloadResult;\n}\n\nexport async function runViteInstall(\n  cwd: string,\n  interactive?: boolean,\n  extraArgs?: string[],\n  options?: { silent?: boolean },\n) {\n  // install dependencies on non-CI environment\n  if (process.env.VITE_PLUS_SKIP_INSTALL) {\n    return { durationMs: 0, status: 'skipped' } satisfies CommandRunSummary;\n  }\n\n  const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive);\n  const startTime = Date.now();\n  spinner.start(`Installing dependencies...`);\n  const { exitCode, stderr, stdout } = await runCommandSilently({\n    command: process.env.VITE_PLUS_CLI_BIN ?? 'vp',\n    args: ['install', ...(extraArgs ?? [])],\n    cwd,\n    envs: process.env,\n  });\n  if (exitCode === 0) {\n    spinner.stop(`Dependencies installed`);\n    return {\n      durationMs: Date.now() - startTime,\n      exitCode,\n      status: 'installed',\n    } satisfies CommandRunSummary;\n  } else {\n    spinner.stop(`Install failed`);\n    prompts.log.info(stdout.toString());\n    prompts.log.error(stderr.toString());\n    prompts.log.info(`You may need to run \"vp install\" manually in ${cwd}`);\n    return {\n      durationMs: Date.now() - startTime,\n      exitCode,\n      status: 'failed',\n    } satisfies CommandRunSummary;\n  }\n}\n\nexport async function runViteFmt(\n  cwd: string,\n  interactive?: boolean,\n  paths?: string[],\n  options?: { silent?: boolean },\n) {\n  const spinner = options?.silent ? getSilentSpinner() : getSpinner(interactive);\n  const startTime = Date.now();\n  spinner.start(`Formatting code...`);\n\n  const { exitCode, stderr, stdout } = await runCommandSilently({\n    command: process.env.VITE_PLUS_CLI_BIN ?? 'vp',\n    args: ['fmt', '--write', ...(paths ?? [])],\n    cwd,\n    envs: process.env,\n  });\n\n  if (exitCode === 0) {\n    spinner.stop(`Code formatted`);\n    return {\n      durationMs: Date.now() - startTime,\n      exitCode,\n      status: 'formatted',\n    } satisfies CommandRunSummary;\n  } else {\n    spinner.stop(`Format failed`);\n    prompts.log.info(stdout.toString());\n    prompts.log.error(stderr.toString());\n    const relativePaths = (paths ?? []).length > 0 ? ` ${(paths ?? []).join(' ')}` : '';\n    prompts.log.info(`You may need to run \"vp fmt --write${relativePaths}\" manually in ${cwd}`);\n    return {\n      durationMs: Date.now() - startTime,\n      exitCode,\n      status: 'failed',\n    } satisfies CommandRunSummary;\n  }\n}\n\nexport async function upgradeYarn(cwd: string, interactive?: boolean, silent = false) {\n  const spinner = silent ? getSilentSpinner() : getSpinner(interactive);\n  spinner.start(`Running yarn set version stable...`);\n  const { exitCode, stderr, stdout } = await runCommandSilently({\n    command: 'yarn',\n    args: ['set', 'version', 'stable'],\n    cwd,\n    envs: process.env,\n  });\n  if (exitCode === 0) {\n    spinner.stop(`Yarn upgraded to stable version`);\n  } else {\n    spinner.stop(`yarn upgrade failed`);\n    prompts.log.info(stdout.toString());\n    prompts.log.error(stderr.toString());\n  }\n}\n\nexport async function promptGitHooks(options: {\n  hooks?: boolean;\n  interactive: boolean;\n}): Promise<boolean> {\n  if (options.hooks === false) {\n    return false;\n  }\n  if (options.hooks === true) {\n    return true;\n  }\n  if (options.interactive) {\n    const selected = await prompts.confirm({\n      message:\n        'Set up pre-commit hooks to run formatting, linting, and type checking with auto-fixes?',\n      initialValue: true,\n    });\n    if (prompts.isCancel(selected)) {\n      cancelAndExit();\n      return false;\n    }\n    return selected;\n  }\n  return true; // non-interactive default\n}\n\nexport function defaultInteractive() {\n  // If CI environment, use non-interactive mode by default\n  return !process.env.CI && process.stdin.isTTY;\n}\n\nexport function getSpinner(interactive?: boolean) {\n  if (interactive) {\n    return prompts.spinner();\n  }\n  return {\n    start: (msg?: string) => {\n      if (msg) {\n        prompts.log.info(msg);\n      }\n    },\n    stop: (msg?: string) => {\n      if (msg) {\n        prompts.log.info(msg);\n      }\n    },\n    message: (msg?: string) => {\n      if (msg) {\n        prompts.log.info(msg);\n      }\n    },\n  };\n}\n\nfunction getSilentSpinner() {\n  return {\n    start: () => {},\n    stop: () => {},\n    message: () => {},\n  };\n}\n"
  },
  {
    "path": "packages/cli/src/utils/skills.ts",
    "content": "import {\n  existsSync,\n  lstatSync,\n  mkdirSync,\n  readFileSync,\n  readdirSync,\n  readlinkSync,\n  realpathSync,\n  symlinkSync,\n} from 'node:fs';\nimport { join, relative } from 'node:path';\n\nimport * as prompts from '@voidzero-dev/vite-plus-prompts';\n\nimport { type AgentConfig } from './agent.js';\nimport { VITE_PLUS_NAME } from './constants.js';\nimport { pkgRoot } from './path.js';\n\ninterface SkillInfo {\n  dirName: string;\n  name: string;\n  description: string;\n}\n\nexport function parseSkills(skillsDir: string): SkillInfo[] {\n  if (!existsSync(skillsDir)) {\n    return [];\n  }\n  const entries = readdirSync(skillsDir, { withFileTypes: true });\n  const skills: SkillInfo[] = [];\n  for (const entry of entries) {\n    if (!entry.isDirectory()) {\n      continue;\n    }\n    const skillMd = join(skillsDir, entry.name, 'SKILL.md');\n    if (!existsSync(skillMd)) {\n      continue;\n    }\n    const content = readFileSync(skillMd, 'utf-8');\n    const frontmatter = content.match(/^---\\n([\\s\\S]*?)\\n---/);\n    if (!frontmatter) {\n      prompts.log.warn(`  Skipping ${entry.name}: SKILL.md is missing valid frontmatter`);\n      continue;\n    }\n    const nameMatch = frontmatter[1].match(/^name:\\s*(.+)$/m);\n    const descMatch = frontmatter[1].match(/^description:\\s*(.+)$/m);\n    skills.push({\n      dirName: entry.name,\n      name: nameMatch ? nameMatch[1].trim() : entry.name,\n      description: descMatch ? descMatch[1].trim() : '',\n    });\n  }\n  return skills;\n}\n\n/** Check if a path exists on disk, including broken symlinks that existsSync misses. */\nfunction pathExists(p: string): boolean {\n  try {\n    lstatSync(p);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nfunction linkSkills(\n  root: string,\n  skillsDir: string,\n  skills: SkillInfo[],\n  agentSkillsDir: string,\n): number {\n  const targetDir = join(root, agentSkillsDir);\n  if (!existsSync(targetDir)) {\n    mkdirSync(targetDir, { recursive: true });\n  }\n\n  const isWindows = process.platform === 'win32';\n  const symlinkType = isWindows ? 'junction' : 'dir';\n\n  let linked = 0;\n  for (const skill of skills) {\n    const linkPath = join(targetDir, skill.dirName);\n    const sourcePath = join(skillsDir, skill.dirName);\n    const relativeTarget = relative(targetDir, sourcePath);\n    const symlinkTarget = isWindows ? sourcePath : relativeTarget;\n\n    if (pathExists(linkPath)) {\n      try {\n        const existing = readlinkSync(linkPath);\n        if (existing === symlinkTarget) {\n          prompts.log.info(`  ${skill.name} — already linked`);\n          continue;\n        }\n      } catch (err: unknown) {\n        if ((err as NodeJS.ErrnoException).code !== 'EINVAL') {\n          prompts.log.warn(\n            `  ${skill.name} — failed to read existing path: ${(err as Error).message}`,\n          );\n          continue;\n        }\n      }\n      prompts.log.warn(`  ${skill.name} — path exists but is not the expected symlink, skipping`);\n      continue;\n    }\n\n    try {\n      symlinkSync(symlinkTarget, linkPath, symlinkType);\n    } catch (err: unknown) {\n      prompts.log.warn(`  ${skill.name} — failed to create symlink: ${(err as Error).message}`);\n      continue;\n    }\n    prompts.log.success(`  ${skill.name} — linked`);\n    linked++;\n  }\n  return linked;\n}\n\nfunction getStableSkillsDir(root: string): string {\n  const resolvedSkillsDir = join(pkgRoot, 'skills');\n  // Prefer the logical node_modules path for a cleaner, stable symlink\n  // (avoids pnpm's versioned .pnpm/pkg@version/... real path)\n  const logicalSkillsDir = join(root, 'node_modules', VITE_PLUS_NAME, 'skills');\n  try {\n    if (realpathSync(logicalSkillsDir) === realpathSync(resolvedSkillsDir)) {\n      return logicalSkillsDir;\n    }\n  } catch {\n    // Fall through to resolved path\n  }\n  return resolvedSkillsDir;\n}\n\nexport function linkSkillsForSpecificAgents(root: string, agentConfigs: AgentConfig[]): number {\n  const skillsDir = getStableSkillsDir(root);\n  const skills = parseSkills(skillsDir);\n  if (skills.length === 0) {\n    return 0;\n  }\n\n  if (agentConfigs.length === 0) {\n    return 0;\n  }\n\n  let totalLinked = 0;\n  for (const agent of agentConfigs) {\n    prompts.log.info(`${agent.displayName} → ${agent.skillsDir}`);\n    totalLinked += linkSkills(root, skillsDir, skills, agent.skillsDir);\n  }\n  return totalLinked;\n}\n"
  },
  {
    "path": "packages/cli/src/utils/terminal.ts",
    "content": "import { styleText } from 'node:util';\n\nexport function log(message: string) {\n  /* oxlint-disable-next-line no-console */\n  console.log(message);\n}\n\nexport function accent(text: string) {\n  return styleText('blue', text);\n}\n\nexport function muted(text: string) {\n  return styleText('gray', text);\n}\n\nexport function success(text: string) {\n  return styleText('green', text);\n}\n\nexport function error(text: string) {\n  return styleText('red', text);\n}\n\n// Standard message prefix functions matching the Rust CLI convention.\n// info/note go to stdout (normal output), warn/error go to stderr (diagnostics).\n\nexport function infoMsg(msg: string) {\n  /* oxlint-disable-next-line no-console */\n  console.log(styleText(['blue', 'bold'], 'info:'), msg);\n}\n\nexport function warnMsg(msg: string) {\n  /* oxlint-disable-next-line no-console */\n  console.error(styleText(['yellow', 'bold'], 'warn:'), msg);\n}\n\nexport function errorMsg(msg: string) {\n  /* oxlint-disable-next-line no-console */\n  console.error(styleText(['red', 'bold'], 'error:'), msg);\n}\n\nexport function noteMsg(msg: string) {\n  /* oxlint-disable-next-line no-console */\n  console.log(styleText(['gray', 'bold'], 'note:'), msg);\n}\n"
  },
  {
    "path": "packages/cli/src/utils/tsconfig.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\n/**\n * Check if tsconfig.json has compilerOptions.baseUrl set.\n * oxlint's TypeScript checker (tsgolint) does not support baseUrl,\n * so typeAware/typeCheck must be disabled when it is present.\n */\nexport function hasBaseUrlInTsconfig(projectPath: string): boolean {\n  try {\n    const tsconfig = JSON.parse(\n      fs.readFileSync(path.join(projectPath, 'tsconfig.json'), 'utf-8'),\n    ) as { compilerOptions?: { baseUrl?: string } };\n    return tsconfig?.compilerOptions?.baseUrl !== undefined;\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/utils/types.ts",
    "content": "export type PackageDependencies = {\n  devDependencies?: Record<string, string>;\n  dependencies?: Record<string, string>;\n};\n"
  },
  {
    "path": "packages/cli/src/utils/workspace.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { globSync } from 'glob';\nimport { minimatch } from 'minimatch';\nimport { Scalar, YAMLSeq } from 'yaml';\n\nimport { detectWorkspace as detectWorkspaceBinding } from '../../binding/index.js';\nimport {\n  DependencyType,\n  PackageManager,\n  type WorkspaceInfo,\n  type WorkspaceInfoOptional,\n  type WorkspacePackage,\n} from '../types/index.js';\nimport { editJsonFile, readJsonFile } from './json.js';\nimport { getScopeFromPackageName } from './package.js';\nimport { editYamlFile, readYamlFile } from './yaml.js';\n\nexport function findPackageJsonFilesFromPatterns(patterns: string[], cwd: string): string[] {\n  if (patterns.length === 0) {\n    return [];\n  }\n  return globSync(\n    patterns.map((pattern) => `${pattern}/package.json`),\n    { absolute: true, cwd },\n  );\n}\n\n// Detect if we're in a monorepo and get workspace info\nexport async function detectWorkspace(rootDir: string): Promise<WorkspaceInfoOptional> {\n  const bindingResult = await detectWorkspaceBinding(rootDir);\n  const result: WorkspaceInfoOptional = {\n    rootDir,\n    packageManager: undefined,\n    packageManagerVersion: 'latest',\n    isMonorepo: false,\n    monorepoScope: '',\n    workspacePatterns: [],\n    parentDirs: [],\n    packages: [],\n  };\n  if (bindingResult.packageManagerName) {\n    result.packageManager = bindingResult.packageManagerName as PackageManager;\n  }\n  if (bindingResult.packageManagerVersion) {\n    result.packageManagerVersion = bindingResult.packageManagerVersion;\n  }\n  if (bindingResult.isMonorepo) {\n    result.isMonorepo = bindingResult.isMonorepo;\n  }\n  if (bindingResult.root) {\n    // automatically correct the root directory from cwd\n    result.rootDir = bindingResult.root;\n  }\n\n  // Extract parent directories from workspace patterns\n  if (result.isMonorepo) {\n    const pnpmWorkspaceFile = path.join(result.rootDir, 'pnpm-workspace.yaml');\n    const packageJsonFile = path.join(result.rootDir, 'package.json');\n    if (fs.existsSync(pnpmWorkspaceFile)) {\n      const workspaceConfig = readYamlFile<{ packages?: string[] }>(pnpmWorkspaceFile);\n      if (Array.isArray(workspaceConfig.packages)) {\n        result.workspacePatterns = workspaceConfig.packages;\n      }\n    } else if (fs.existsSync(packageJsonFile)) {\n      // Check for npm/yarn workspace\n      const pkg = readJsonFile<{ workspaces?: string[] }>(packageJsonFile);\n      if (Array.isArray(pkg.workspaces)) {\n        result.workspacePatterns = pkg.workspaces;\n      }\n    }\n\n    const dirs = new Set<string>();\n    for (const pattern of result.workspacePatterns) {\n      // Extract directory from patterns like \"apps/*\", \"packages/*\", \"foo/bar/*\", \"website\", etc\n      if (!pattern.endsWith('*')) {\n        continue;\n      }\n      // Extract the directory name, ignore the wildcard\n      const dir = pattern.replace(/\\/\\*{1,2}$/, '');\n      if (dir) {\n        dirs.add(dir);\n      }\n    }\n    // eslint-disable-next-line unicorn/no-array-sort -- safe: sorting a fresh Array.from copy\n    result.parentDirs = Array.from(dirs).sort();\n\n    // Extract the scope from the package.json\n    const pkg = readJsonFile<{ name?: string }>(packageJsonFile);\n    if (pkg.name) {\n      result.monorepoScope = getScopeFromPackageName(pkg.name);\n    }\n    result.packages = discoverWorkspacePackages(result.workspacePatterns, result.rootDir);\n  }\n\n  return result;\n}\n\n// Discover all workspace packages\nexport function discoverWorkspacePackages(\n  workspacePatterns: string[],\n  rootDir: string,\n): WorkspacePackage[] {\n  const packages: WorkspacePackage[] = [];\n\n  if (workspacePatterns.length === 0) {\n    return packages;\n  }\n\n  // Find all package.json files in the workspace\n  const packageJsonRelativePaths = globSync(\n    workspacePatterns.map((pattern) => `${pattern}/package.json`),\n    {\n      absolute: false,\n      cwd: rootDir,\n      ignore: ['**/node_modules/**'],\n    },\n  );\n  for (const packageJsonRelativePath of packageJsonRelativePaths) {\n    const packageJsonPath = path.join(rootDir, packageJsonRelativePath);\n    const pkg = readJsonFile<{\n      name?: string;\n      description?: string;\n      version?: string;\n      dependencies?: Record<string, string>;\n      keywords?: string[];\n    }>(packageJsonPath);\n    if (!pkg.name) {\n      continue;\n    }\n    const isTemplatePackage =\n      pkg.keywords?.includes('vite-plus-template') ||\n      pkg.keywords?.includes('bingo-template') ||\n      !!pkg.dependencies?.bingo;\n    packages.push({\n      name: pkg.name,\n      path: path.dirname(packageJsonRelativePath),\n      description: pkg.description,\n      version: pkg.version,\n      isTemplatePackage,\n    });\n  }\n\n  return packages;\n}\n\n// Update package.json with workspace dependencies\nexport function updatePackageJsonWithDeps(\n  rootDir: string,\n  projectDir: string,\n  dependencies: string[],\n  dependencyType: DependencyType,\n) {\n  const packageJsonPath = path.join(rootDir, projectDir, 'package.json');\n  editJsonFile<{ [key in DependencyType]?: Record<string, string> }>(packageJsonPath, (pkg) => {\n    if (!pkg[dependencyType]) {\n      pkg[dependencyType] = {};\n    }\n    for (const dep of dependencies) {\n      pkg[dependencyType][dep] = 'workspace:*';\n    }\n    return pkg;\n  });\n}\n\n// Update workspace configuration to include new project\nexport function updateWorkspaceConfig(projectPath: string, workspaceInfo: WorkspaceInfo) {\n  // Check if project path matches any workspace pattern\n  for (const pattern of workspaceInfo.workspacePatterns) {\n    if (minimatch(projectPath, pattern)) {\n      return;\n    }\n  }\n\n  // Derive pattern from project path (e.g., \"packages/my-app\" -> \"packages/*\", \"website\" -> \"website\", \"foo/bar/app\" -> \"foo/bar/*\")\n  let pattern = path.dirname(projectPath);\n  if (!pattern) {\n    // \"website\" -> \"website\"\n    pattern = projectPath;\n  } else {\n    // \"foo/bar/app\" -> \"foo/bar/*\"\n    pattern = `${pattern}/*`;\n  }\n\n  if (workspaceInfo.packageManager === PackageManager.pnpm) {\n    editYamlFile(path.join(workspaceInfo.rootDir, 'pnpm-workspace.yaml'), (doc) => {\n      let packages = doc.getIn(['packages']) as YAMLSeq<Scalar<string>>;\n      if (!packages) {\n        packages = new YAMLSeq<Scalar<string>>();\n      }\n      packages.add(new Scalar(pattern));\n      doc.setIn(['packages'], packages);\n    });\n  } else {\n    // Update package.json workspaces\n    editJsonFile<{ workspaces?: string[] }>(\n      path.join(workspaceInfo.rootDir, 'package.json'),\n      (pkg) => {\n        pkg.workspaces = [...(pkg.workspaces || []), pattern];\n        return pkg;\n      },\n    );\n  }\n}\n"
  },
  {
    "path": "packages/cli/src/utils/yaml.ts",
    "content": "import fs from 'node:fs';\n\nimport { type Document, parseDocument, parse as parseYaml, Scalar } from 'yaml';\n\nexport function readYamlFile<T = Record<string, unknown>>(file: string): T {\n  const content = fs.readFileSync(file, 'utf-8');\n  return parseYaml(content) as T;\n}\n\nexport type YamlDocument = Document.Parsed;\n\nexport function editYamlFile(file: string, callback: (doc: YamlDocument) => void) {\n  const content = fs.readFileSync(file, 'utf-8');\n  const doc = parseDocument(content);\n  callback(doc);\n  // prefer single quotes\n  fs.writeFileSync(file, doc.toString({ singleQuote: true }), 'utf-8');\n}\n\nexport function scalarString(value: string): Scalar<string> {\n  return new Scalar(value);\n}\n"
  },
  {
    "path": "packages/cli/src/version.ts",
    "content": "import fs from 'node:fs';\nimport { createRequire } from 'node:module';\nimport path from 'node:path';\n\nimport { vitePlusHeader } from '../binding/index.js';\nimport { VITE_PLUS_NAME } from './utils/constants.js';\nimport { renderCliDoc } from './utils/help.js';\nimport { detectPackageMetadata, hasVitePlusDependency } from './utils/package.js';\nimport { accent, log } from './utils/terminal.js';\n\nconst require = createRequire(import.meta.url);\n\ninterface PackageJson {\n  version?: string;\n  bundledVersions?: Record<string, string>;\n  dependencies?: Record<string, string>;\n  devDependencies?: Record<string, string>;\n}\n\ninterface LocalPackageMetadata {\n  name: string;\n  version: string;\n  path: string;\n}\n\ninterface ToolVersionSpec {\n  displayName: string;\n  packageName: string;\n  bundledVersionKey?: string;\n  fallbackPackageJson?: string;\n}\n\nfunction getGlobalVersion(): string | null {\n  return process.env.VITE_PLUS_GLOBAL_VERSION ?? null;\n}\n\nfunction getCliVersion(): string | null {\n  const pkg = resolvePackageJson(VITE_PLUS_NAME, process.cwd());\n  return pkg?.version ?? null;\n}\n\nfunction getLocalMetadata(cwd: string): LocalPackageMetadata | null {\n  if (!isVitePlusDeclaredInAncestors(cwd)) {\n    return null;\n  }\n  return detectPackageMetadata(cwd, VITE_PLUS_NAME) ?? null;\n}\n\nfunction isVitePlusDeclaredInAncestors(cwd: string): boolean {\n  let currentDir = path.resolve(cwd);\n  while (true) {\n    const packageJsonPath = path.join(currentDir, 'package.json');\n    const pkg = readPackageJsonFromPath(packageJsonPath);\n    if (pkg && hasVitePlusDependency(pkg)) {\n      return true;\n    }\n    const parentDir = path.dirname(currentDir);\n    if (parentDir === currentDir) {\n      break;\n    }\n    currentDir = parentDir;\n  }\n  return false;\n}\n\nfunction readPackageJsonFromPath(packageJsonPath: string): PackageJson | null {\n  try {\n    return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJson;\n  } catch {\n    return null;\n  }\n}\n\nfunction resolvePackageJson(packageName: string, baseDir: string): PackageJson | null {\n  try {\n    // Try resolving package.json subpath directly\n    const packageJsonPath = require.resolve(`${packageName}/package.json`, {\n      paths: [baseDir],\n    });\n    return readPackageJsonFromPath(packageJsonPath);\n  } catch {\n    // Fallback for packages with restricted exports that don't expose ./package.json:\n    // resolve the main entry and find package.json relative to it\n    try {\n      const mainPath = require.resolve(packageName, { paths: [baseDir] });\n      // Walk up from the resolved entry to find the package.json\n      let dir = path.dirname(mainPath);\n      while (dir !== path.dirname(dir)) {\n        const pkgPath = path.join(dir, 'package.json');\n        const pkg = readPackageJsonFromPath(pkgPath);\n        if (pkg) {\n          return pkg;\n        }\n        dir = path.dirname(dir);\n      }\n    } catch {\n      // package not found at all\n    }\n    return null;\n  }\n}\n\nfunction resolveToolVersion(tool: ToolVersionSpec, localPackagePath: string): string | null {\n  const pkg = resolvePackageJson(tool.packageName, localPackagePath);\n  const bundledVersion = tool.bundledVersionKey\n    ? (pkg?.bundledVersions?.[tool.bundledVersionKey] ?? null)\n    : null;\n  if (bundledVersion) {\n    return bundledVersion;\n  }\n  const version = pkg?.version ?? null;\n  if (version) {\n    return version;\n  }\n  if (tool.fallbackPackageJson) {\n    const fallbackPath = path.join(localPackagePath, tool.fallbackPackageJson);\n    return readPackageJsonFromPath(fallbackPath)?.version ?? null;\n  }\n  return null;\n}\n\n/**\n * Print version information\n */\nexport async function printVersion(cwd: string) {\n  const globalVersion = getGlobalVersion();\n  const cliVersion = getCliVersion();\n  const localMetadata = getLocalMetadata(cwd);\n  const localVersion = localMetadata?.version ?? null;\n  const vpVersion = globalVersion ?? cliVersion ?? localVersion ?? 'unknown';\n\n  log(vitePlusHeader());\n  log('');\n  log(`vp v${vpVersion}\\n`);\n\n  const sections = [\n    {\n      title: 'Local vite-plus',\n      rows: [\n        {\n          label: accent('vite-plus'),\n          description: localVersion ? `v${localVersion}` : 'Not found',\n        },\n      ],\n    },\n  ];\n\n  const tools: ToolVersionSpec[] = [\n    {\n      displayName: 'vite',\n      packageName: '@voidzero-dev/vite-plus-core',\n      bundledVersionKey: 'vite',\n    },\n    {\n      displayName: 'rolldown',\n      packageName: '@voidzero-dev/vite-plus-core',\n      bundledVersionKey: 'rolldown',\n    },\n    {\n      displayName: 'vitest',\n      packageName: '@voidzero-dev/vite-plus-test',\n      bundledVersionKey: 'vitest',\n    },\n    {\n      displayName: 'oxfmt',\n      packageName: 'oxfmt',\n    },\n    {\n      displayName: 'oxlint',\n      packageName: 'oxlint',\n    },\n    {\n      displayName: 'oxlint-tsgolint',\n      packageName: 'oxlint-tsgolint',\n    },\n    {\n      displayName: 'tsdown',\n      packageName: '@voidzero-dev/vite-plus-core',\n      bundledVersionKey: 'tsdown',\n    },\n  ];\n\n  if (localMetadata) {\n    const resolvedTools = tools.map((tool) => ({\n      tool,\n      version: resolveToolVersion(tool, localMetadata.path),\n    }));\n\n    sections.push({\n      title: 'Tools',\n      rows: resolvedTools.map(({ tool, version }) => ({\n        label: accent(tool.displayName),\n        description: version ? `v${version}` : 'Not found',\n      })),\n    });\n  }\n\n  log(renderCliDoc({ sections }));\n}\n\nawait printVersion(process.cwd());\n"
  },
  {
    "path": "packages/cli/templates/generator/README.md",
    "content": "# Vite+ Code Generator Starter\n\nA starter for creating a Vite+ code generator.\n\n## Usage\n\nFrom monorepo root:\n\n```bash\n# run and select the generator\nvp create\n```\n\n## Development\n\n```bash\n# Edit the template\ncode src/template.ts\n\n# Test the generator CLI\nvp run dev\n\n# Run tests\nvp run test\n```\n\n## Customization\n\nEdit `src/template.ts` to customize:\n\n- Options schema (using Zod)\n- File generation logic\n- Scripts and suggestions\n\nMore information about the [Bingo Templates](https://create.bingo/) can be found [here](https://create.bingo/build/concepts/creations).\n"
  },
  {
    "path": "packages/cli/templates/generator/bin/index.ts",
    "content": "#!/usr/bin/env node\n\nimport { runTemplateCLI } from 'bingo';\n\nimport template from '../src/template.ts';\n\nprocess.exitCode = await runTemplateCLI(template);\n"
  },
  {
    "path": "packages/cli/templates/generator/package.json",
    "content": "{\n  \"name\": \"vite-plus-generator-template\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"description\": \"A starter for creating a Vite+ code generator.\",\n  \"keywords\": [\n    \"bingo-template\",\n    \"vite-plus-generator\"\n  ],\n  \"bin\": \"./bin/index.ts\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"test\": \"vp test\",\n    \"dev\": \"node bin/index.ts\"\n  },\n  \"dependencies\": {\n    \"bingo\": \"^0.7.0\",\n    \"zod\": \"^3.25.76\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/templates/generator/src/template.ts",
    "content": "import { createTemplate } from 'bingo';\nimport { z } from 'zod';\n\nimport pkgJson from '../package.json' with { type: 'json' };\n\nexport default createTemplate({\n  about: {\n    name: pkgJson.name,\n    description: pkgJson.description,\n  },\n\n  // Define your options using Zod schemas\n  options: {\n    name: z.string().describe('Package name'),\n    // TODO: Add more options as needed\n  },\n\n  // Generate files based on options\n  async produce({ options }) {\n    return {\n      // see https://www.create.bingo/build/concepts/creations#files\n      files: {\n        'package.json': JSON.stringify(\n          {\n            // @ts-expect-error\n            name: options.name,\n            version: '0.0.0',\n            type: 'module',\n            // TODO: Add more package.json fields\n          },\n          null,\n          2,\n        ),\n        src: {\n          // @ts-expect-error\n          'index.ts': `export const name = '${options.name}';\n`,\n        },\n        'tsconfig.json': JSON.stringify(\n          {\n            compilerOptions: {\n              declaration: true,\n              esModuleInterop: true,\n              module: 'NodeNext',\n              moduleResolution: 'NodeNext',\n              outDir: 'lib',\n              skipLibCheck: true,\n              strict: true,\n              target: 'ES2022',\n            },\n            include: ['src'],\n          },\n          null,\n          2,\n        ),\n        // TODO: Add more files\n      },\n      // see https://www.create.bingo/build/concepts/creations#scripts\n      scripts: [\n        // Optional: Add scripts to run after generation\n      ],\n      // see https://www.create.bingo/build/concepts/creations#suggestions\n      suggestions: [\n        // Optional: Add suggestions for users\n      ],\n    };\n  },\n});\n"
  },
  {
    "path": "packages/cli/templates/generator/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowImportingTsExtensions\": true,\n    \"module\": \"nodenext\",\n    \"moduleResolution\": \"nodenext\",\n    \"noEmit\": true,\n    \"resolveJsonModule\": true,\n    \"types\": [\"node\"]\n  }\n}\n"
  },
  {
    "path": "packages/cli/templates/monorepo/README.md",
    "content": "# Vite+ Monorepo Starter\n\nA starter for creating a Vite+ monorepo.\n\n## Development\n\n- Check everything is ready:\n\n```bash\nvp run ready\n```\n\n- Run the tests:\n\n```bash\nvp run test -r\n```\n\n- Build the monorepo:\n\n```bash\nvp run build -r\n```\n\n- Run the development server:\n\n```bash\nvp run dev\n```\n"
  },
  {
    "path": "packages/cli/templates/monorepo/_gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "packages/cli/templates/monorepo/_yarnrc.yml",
    "content": "# used for install vite-plus\ncatalog:\n  '@types/node': ^24\n  typescript: ^5\n"
  },
  {
    "path": "packages/cli/templates/monorepo/package.json",
    "content": "{\n  \"name\": \"vite-plus-monorepo-template\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"workspaces\": [\n    \"packages/*\",\n    \"apps/*\",\n    \"tools/*\"\n  ],\n  \"type\": \"module\",\n  \"scripts\": {\n    \"ready\": \"vp fmt && vp lint && vp run test -r && vp run build -r\",\n    \"dev\": \"vp run website#dev\"\n  },\n  \"engines\": {\n    \"node\": \">=22.12.0\"\n  }\n}\n"
  },
  {
    "path": "packages/cli/templates/monorepo/pnpm-workspace.yaml",
    "content": "packages:\n  - apps/*\n  - packages/*\n  - tools/*\n\ncatalogMode: prefer\n\ncatalog:\n  '@types/node': ^24\n  typescript: ^5\n"
  },
  {
    "path": "packages/cli/templates/monorepo/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"noEmit\": true,\n    \"module\": \"nodenext\",\n    \"moduleResolution\": \"nodenext\",\n    \"allowImportingTsExtensions\": true,\n    \"esModuleInterop\": true\n  }\n}\n"
  },
  {
    "path": "packages/cli/templates/monorepo/vite.config.ts",
    "content": "import { defineConfig } from 'vite';\n\nexport default defineConfig({});\n"
  },
  {
    "path": "packages/cli/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"experimentalDecorators\": true\n  },\n  \"files\": [],\n  \"include\": [],\n  \"exclude\": [\"**/*\"]\n}\n"
  },
  {
    "path": "packages/core/.gitignore",
    "content": "/LICENSE\n/LICENSE.md\n"
  },
  {
    "path": "packages/core/BUNDLING.md",
    "content": "# Core Package Bundling Architecture\n\nThis document explains how `@voidzero-dev/vite-plus-core` bundles multiple upstream projects into a single unified package.\n\n## Overview\n\nThe core package uses a **multi-project bundling strategy** that combines 5 upstream projects:\n\n| Project                 | Source Location                 | Purpose                   |\n| ----------------------- | ------------------------------- | ------------------------- |\n| `@rolldown/pluginutils` | `rolldown/packages/pluginutils` | Rolldown plugin utilities |\n| `rolldown`              | `rolldown/packages/rolldown`    | Rolldown bundler          |\n| `vite`                  | `vite/packages/vite`            | Vite v8 beta              |\n| `tsdown`                | `node_modules/tsdown`           | TypeScript build tool     |\n| `vitepress`             | `node_modules/vitepress`        | Documentation tool        |\n\nThis approach enables users to access Vite, Rolldown, and related tools through a single package with consistent module specifier rewrites.\n\n---\n\n## Build Steps\n\nThe build process executes 6 steps in sequence:\n\n### Step 1: Bundle Rolldown Pluginutils (`bundleRolldownPluginutils`)\n\n**Action**: Copies pre-built dist directory.\n\n```typescript\nawait cp(join(rolldownPluginUtilsDir, 'dist'), join(projectDir, 'dist', 'pluginutils'), {\n  recursive: true,\n});\n```\n\n**Input**: `rolldown/packages/pluginutils/dist/`\n**Output**: `dist/pluginutils/`\n\n### Step 2: Bundle Rolldown (`bundleRolldown`)\n\n**Action**: Copies dist directory and rewrites module specifiers.\n\n**Transformations**:\n\n- `@rolldown/pluginutils` → `@voidzero-dev/vite-plus-core/rolldown/pluginutils`\n- `rolldown/*` → `@voidzero-dev/vite-plus-core/rolldown/*`\n- In release builds: `@rolldown/binding-*` → `vite-plus/binding`\n\n**Input**: `rolldown/packages/rolldown/dist/`\n**Output**: `dist/rolldown/`\n\n### Step 3: Build Vite (`buildVite`)\n\n**Action**: Full Rolldown build with custom transforms.\n\nThis is the most complex step, using the upstream `vite-rolldown.config` with modifications:\n\n1. **Filter externals** - Bundles `picomatch`, `tinyglobby`, `fdir`, `rolldown`, `yaml` instead of keeping them external\n2. **Add RewriteImportsPlugin** - Rewrites vite/rolldown imports at build time\n3. **Rewrite static paths** - Fixes `VITE_PACKAGE_DIR`, `CLIENT_ENTRY`, `ENV_ENTRY` constants\n4. **Copy additional files** - `misc/`, `.d.ts` files, `types/`, `client.d.ts`\n\n**Input**: `vite/packages/vite/`\n**Output**: `dist/vite/`\n\n### Step 4: Bundle Tsdown (`bundleTsdown`)\n\n**Action**: Re-bundles tsdown with CJS dependency handling.\n\n**Process**:\n\n1. Bundle `tsdown/dist/run.mjs` and `tsdown/dist/index.mjs` using Rolldown\n2. Detect third-party CJS modules using `find-create-require.ts`\n3. Bundle detected CJS dependencies using `build-cjs-deps.ts`\n4. Bundle type declarations using `rolldown-plugin-dts`\n\n**Input**: `node_modules/tsdown/dist/`\n**Output**: `dist/tsdown/`\n\n### Step 5: Bundle Vitepress (`bundleVitepress`)\n\n**Action**: Copies dist directory and rewrites vite imports.\n\n**Transformations**:\n\n- `vite` → `@voidzero-dev/vite-plus-core/vite`\n\n**Input**: `node_modules/vitepress/`\n**Output**: `dist/vitepress/`\n\n### Step 6: Merge Package.json (`mergePackageJson`)\n\n**Action**: Merges metadata from upstream packages and records bundled versions.\n\n**Updates**:\n\n- `peerDependencies` - Merged from tsdown and vite\n- `peerDependenciesMeta` - Merged from tsdown and vite\n- `bundledVersions` - Records vite, rolldown, and tsdown versions\n\n---\n\n## Module Specifier Rewriting System\n\nThe build uses two complementary rewriting mechanisms:\n\n### Build-Time Rewriting (RewriteImportsPlugin)\n\nLocated in `build-support/rewrite-imports.ts`, this Rolldown plugin rewrites imports during bundling:\n\n```typescript\nexport const RewriteImportsPlugin: Plugin = {\n  name: 'rewrite-imports-for-vite-plus',\n  resolveId: {\n    order: 'pre',\n    handler(id: string) {\n      if (id.startsWith('vite/')) {\n        return { id: id.replace(/^vite\\//, `${pkgJson.name}/`), external: true };\n      }\n      if (id === 'rolldown') {\n        return { id: `${pkgJson.name}/rolldown`, external: true };\n      }\n      if (id.startsWith('rolldown/')) {\n        return { id: id.replace(/^rolldown\\//, `${pkgJson.name}/rolldown/`), external: true };\n      }\n    },\n  },\n};\n```\n\n### Post-Build Rewriting (AST-grep)\n\nLocated in `build-support/rewrite-module-specifiers.ts`, this utility rewrites specifiers in already-built files using AST-grep:\n\n| Original Import           | Rewritten Import                                      |\n| ------------------------- | ----------------------------------------------------- |\n| `vite`                    | `@voidzero-dev/vite-plus-core`                        |\n| `vite/*`                  | `@voidzero-dev/vite-plus-core/*`                      |\n| `rolldown`                | `@voidzero-dev/vite-plus-core/rolldown`               |\n| `rolldown/*`              | `@voidzero-dev/vite-plus-core/rolldown/*`             |\n| `@rolldown/pluginutils`   | `@voidzero-dev/vite-plus-core/rolldown/pluginutils`   |\n| `@rolldown/pluginutils/*` | `@voidzero-dev/vite-plus-core/rolldown/pluginutils/*` |\n\n### Release Build: Native Binding Rewriting\n\nDuring release builds (`RELEASE_BUILD=1`), an additional critical transformation occurs for Rolldown's native bindings:\n\n```typescript\n// In bundleRolldown()\nif (process.env.RELEASE_BUILD) {\n  // @rolldown/binding-darwin-arm64 → vite-plus/binding\n  source = source.replace(/@rolldown\\/binding-([a-z0-9-]+)/g, 'vite-plus/binding');\n  // Sync version strings\n  source = source.replaceAll(`${rolldownBindingVersion}`, pkgJson.version);\n}\n```\n\n**Platform-specific binding rewrites**:\n\n| Original Import                     | Rewritten Import    |\n| ----------------------------------- | ------------------- |\n| `@rolldown/binding-darwin-arm64`    | `vite-plus/binding` |\n| `@rolldown/binding-darwin-x64`      | `vite-plus/binding` |\n| `@rolldown/binding-linux-arm64-gnu` | `vite-plus/binding` |\n| `@rolldown/binding-linux-x64-gnu`   | `vite-plus/binding` |\n| `@rolldown/binding-win32-x64-msvc`  | `vite-plus/binding` |\n\n**Why this matters**:\n\n1. **Self-contained distribution** - Users don't need to install separate `@rolldown/binding-*` packages\n2. **Version alignment** - The rolldown binding version is synced to the vite-plus version\n3. **Single native module** - The `vite-plus/binding` export points to the CLI's compiled `.node` file which includes `rolldown_binding` when built with `RELEASE_BUILD=1`\n\n**Resolution chain**:\n\n```\nUser code imports '@voidzero-dev/vite-plus-core/rolldown'\n  → dist/rolldown/index.mjs\n    → imports 'vite-plus/binding' (rewritten from @rolldown/binding-*)\n      → vite-plus CLI package ./binding export\n        → binding/vite-plus.darwin-arm64.node (contains rolldown_binding)\n```\n\nSee [CLI Package Bundling](../cli/BUNDLING.md#rolldown-native-binding-integration) for details on how the CLI compiles rolldown bindings.\n\n---\n\n## CJS Dependency Handling\n\nTsdown uses `createRequire()` to load some CommonJS dependencies. These are detected and bundled specially:\n\n### Detection (`find-create-require.ts`)\n\nUses `oxc-parser` to find patterns like:\n\n```javascript\n// Pattern 1: Static import\nimport { createRequire } from 'node:module';\nconst require = createRequire(import.meta.url);\nrequire('some-cjs-package');\n\n// Pattern 2: Global module\nconst require = globalThis.process.getBuiltinModule('module').createRequire(import.meta.url);\nrequire('some-cjs-package');\n```\n\n### Bundling (`build-cjs-deps.ts`)\n\nCreates CJS entry files and bundles them with Rolldown:\n\n```typescript\n// Creates: npm_entry_some_cjs_package.cjs\nmodule.exports = require('some-cjs-package');\n```\n\nThe original `require(\"some-cjs-package\")` calls are rewritten to `require(\"./npm_entry_some_cjs_package.cjs\")`.\n\n---\n\n## Output Structure\n\n```\ndist/\n├── pluginutils/           # @rolldown/pluginutils\n│   ├── index.js\n│   ├── index.d.ts\n│   └── filter/\n├── rolldown/              # Rolldown bundler\n│   ├── index.mjs\n│   ├── index.d.mts\n│   ├── config.mjs\n│   ├── experimental-index.mjs\n│   ├── filter-index.mjs\n│   ├── parallel-plugin.mjs\n│   ├── parse-ast-index.mjs\n│   ├── plugins-index.mjs\n│   └── ...\n├── vite/                  # Vite\n│   ├── node/\n│   │   ├── index.js\n│   │   ├── index.d.ts\n│   │   ├── internal.js\n│   │   ├── module-runner.js\n│   │   └── chunks/\n│   ├── client/\n│   │   ├── client.mjs\n│   │   └── env.mjs\n│   ├── misc/\n│   ├── types/\n│   └── client.d.ts\n├── tsdown/                # TypeScript build tool\n│   ├── index.js\n│   ├── index-types.d.ts\n│   ├── run.js\n│   └── npm_entry_*.cjs    # Bundled CJS deps\n└── vitepress/             # Documentation tool\n    ├── dist/\n    ├── types/\n    ├── client.d.ts\n    ├── theme.d.ts\n    └── theme-without-fonts.d.ts\n```\n\n---\n\n## Package Exports\n\n| Export Path                     | Points To                                | Description             |\n| ------------------------------- | ---------------------------------------- | ----------------------- |\n| `.`                             | `./dist/vite/node/index.js`              | Vite main entry         |\n| `./client`                      | types: `./dist/vite/client.d.ts`         | Client ambient types    |\n| `./dist/client/*`               | `./dist/vite/client/*`                   | Client runtime files    |\n| `./internal`                    | `./dist/vite/node/internal.js`           | Internal Vite APIs      |\n| `./lib`                         | `./dist/tsdown/index.js`                 | Tsdown library          |\n| `./module-runner`               | `./dist/vite/node/module-runner.js`      | Vite module runner      |\n| `./rolldown`                    | `./dist/rolldown/index.mjs`              | Rolldown main entry     |\n| `./rolldown/config`             | `./dist/rolldown/config.mjs`             | Rolldown config helpers |\n| `./rolldown/experimental`       | `./dist/rolldown/experimental-index.mjs` | Experimental features   |\n| `./rolldown/filter`             | `./dist/rolldown/filter-index.mjs`       | Filter utilities        |\n| `./rolldown/parallelPlugin`     | `./dist/rolldown/parallel-plugin.mjs`    | Parallel plugin support |\n| `./rolldown/parseAst`           | `./dist/rolldown/parse-ast-index.mjs`    | AST parsing             |\n| `./rolldown/plugins`            | `./dist/rolldown/plugins-index.mjs`      | Built-in plugins        |\n| `./rolldown/pluginutils`        | `./dist/pluginutils/index.js`            | Plugin utilities        |\n| `./rolldown/pluginutils/filter` | `./dist/pluginutils/filter/index.js`     | Filter utilities        |\n| `./types/*`                     | `./dist/vite/types/*`                    | Type definitions        |\n\n---\n\n## Source Directories\n\n| Upstream Project        | Source Location                       | Relation       |\n| ----------------------- | ------------------------------------- | -------------- |\n| `@rolldown/pluginutils` | `../../rolldown/packages/pluginutils` | Git submodule  |\n| `rolldown`              | `../../rolldown/packages/rolldown`    | Git submodule  |\n| `vite`                  | `../../vite/packages/vite`            | Git submodule  |\n| `tsdown`                | `node_modules/tsdown`                 | npm dependency |\n| `vitepress`             | `node_modules/vitepress`              | npm dependency |\n\n---\n\n## Build Dependencies\n\n| Package               | Purpose                               |\n| --------------------- | ------------------------------------- |\n| `rolldown`            | Bundler for building vite and tsdown  |\n| `rolldown-plugin-dts` | TypeScript declaration bundling       |\n| `@ast-grep/napi`      | Post-build module specifier rewriting |\n| `oxc-parser`          | CJS require detection in tsdown       |\n| `oxfmt`               | Code formatting for package.json      |\n| `tinyglobby`          | File globbing for copying files       |\n\n---\n\n## Maintenance: Updating Bundled Versions\n\n### Updating Vite\n\n1. Update the `vite` git submodule to the new version\n2. Run `pnpm -C packages/core build`\n3. Verify `bundledVersions.vite` in `package.json` is updated\n4. Test with `pnpm test`\n\n### Updating Rolldown\n\n1. Update the `rolldown` git submodule to the new version\n2. Run `pnpm -C packages/core build`\n3. Verify `bundledVersions.rolldown` in `package.json` is updated\n4. Test with `pnpm test`\n\n### Updating Tsdown\n\n1. Update `tsdown` version in `devDependencies`\n2. Run `pnpm install`\n3. Run `pnpm -C packages/core build`\n4. Check for new CJS dependencies (build will detect them automatically)\n5. Verify `bundledVersions.tsdown` in `package.json` is updated\n6. Test with `pnpm test`\n\n### Updating Vitepress\n\n1. Update `vitepress` version in `devDependencies`\n2. Run `pnpm install`\n3. Run `pnpm -C packages/core build`\n4. Test documentation builds\n\n---\n\n## Build Commands\n\n```bash\n# Build the core package\npnpm -C packages/core build\n\n# Release build (rewrites @rolldown/binding-* to vite-plus/binding)\nRELEASE_BUILD=1 pnpm -C packages/core build\n```\n\n---\n\n## Technical Reference\n\n### Build Flow\n\n```\n1. bundleRolldownPluginutils()    Copy pre-built dist\n2. bundleRolldown()               Copy + rewrite module specifiers\n3. buildVite()                    Full Rolldown build with transforms\n   ├── Apply RewriteImportsPlugin     Build-time import rewriting\n   ├── Apply rewrite-static-paths     Fix VITE_PACKAGE_DIR constants\n   ├── Run Rolldown build             Bundle vite source\n   └── Copy and rewrite .d.ts files   Post-build specifier rewriting\n4. bundleTsdown()                 Re-bundle with CJS handling\n   ├── Bundle tsdown with Rolldown    Find CJS modules\n   ├── buildCjsDeps()                 Bundle detected CJS deps\n   └── Bundle types with dts plugin   Generate declarations\n5. bundleVitepress()              Copy + rewrite vite imports\n6. mergePackageJson()             Merge metadata + record versions\n```\n\n### Key Constants\n\n```typescript\n// Source directories\nconst rolldownPluginUtilsDir = resolve(\n  projectDir,\n  '..',\n  '..',\n  'rolldown',\n  'packages',\n  'pluginutils',\n);\nconst rolldownSourceDir = resolve(projectDir, '..', '..', 'rolldown', 'packages', 'rolldown');\nconst rolldownViteSourceDir = resolve(projectDir, '..', '..', 'vite', 'packages', 'vite');\nconst tsdownSourceDir = resolve(projectDir, 'node_modules/tsdown');\n\n// Package name used for rewrites\nconst targetPackage = '@voidzero-dev/vite-plus-core';\n```\n\n### Bundled Versions Tracking\n\nThe `bundledVersions` field in `package.json` records the exact versions of bundled upstream projects:\n\n```json\n{\n  \"bundledVersions\": {\n    \"vite\": \"8.0.0-beta.8\",\n    \"rolldown\": \"1.0.0-beta.60\",\n    \"tsdown\": \"0.20.0-beta.4\"\n  }\n}\n```\n\nThis is automatically updated by `mergePackageJson()` during each build.\n"
  },
  {
    "path": "packages/core/__tests__/build-artifacts.spec.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\nimport url from 'node:url';\n\nimport { describe, expect, it } from 'vitest';\n\nconst coreDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');\nconst distDir = path.join(coreDir, 'dist');\n\ndescribe('build artifacts', () => {\n  it('should include esm-shims.js in dist for tsdown shims support', () => {\n    const shimsPath = path.join(distDir, 'esm-shims.js');\n    expect(fs.existsSync(shimsPath), `${shimsPath} should exist`).toBe(true);\n\n    const content = fs.readFileSync(shimsPath, 'utf8');\n    expect(content).toContain('__dirname');\n    expect(content).toContain('__filename');\n  });\n});\n"
  },
  {
    "path": "packages/core/build-support/build-cjs-deps.ts",
    "content": "import { writeFile, rm } from 'node:fs/promises';\nimport { join } from 'node:path';\n\nimport { build } from 'rolldown';\n\nexport function createModuleEntryFileName(module: string) {\n  // remove the .js extension in the require path\n  // like `require('semver/functions/coerce.js') -> npm_entry_semver_functions_coerce.cjs`\n  return `npm_entry_${module.replaceAll('/', '_').replace('.js', '')}.cjs`;\n}\n\nexport async function buildCjsDeps(modules: Set<string>, distDir: string) {\n  const distFiles = new Set<string>();\n  for (const module of modules) {\n    const filename = createModuleEntryFileName(module);\n    const distFile = join(distDir, `_${filename}`);\n    await writeFile(distFile, `module.exports = require('${module}')\\n`);\n    distFiles.add(distFile);\n  }\n  if (distFiles.size === 0) {\n    return;\n  }\n  await build({\n    input: Array.from(distFiles),\n    platform: 'node',\n    treeshake: true,\n    output: {\n      format: 'cjs',\n      dir: distDir,\n      entryFileNames: (chunkInfo) => {\n        return `${chunkInfo.name.slice(1)}.cjs`;\n      },\n      chunkFileNames: (chunkInfo) => {\n        return `npm_cjs_chunk_${chunkInfo.name || 'index'}.cjs`;\n      },\n    },\n  });\n\n  for (const file of distFiles) {\n    await rm(file);\n  }\n}\n"
  },
  {
    "path": "packages/core/build-support/find-create-require.ts",
    "content": "import { builtinModules } from 'node:module';\n\nimport {\n  parse,\n  type ParseResult,\n  Visitor,\n  type CallExpression,\n  type Expression,\n  type StaticMemberExpression,\n  type VariableDeclarator,\n} from 'oxc-parser';\n\nimport { createModuleEntryFileName } from './build-cjs-deps';\n\n// Node.js built-in modules (without node: prefix)\nconst nodeBuiltins = new Set(builtinModules);\n\n// TODO, analysis the optional peerDependencies in the dependencies tree to exclude them in the future\nconst optionalCjsExternal = new Set<string>(['oxc-resolver', 'synckit']);\n\n/**\n * Check if a module specifier is a third-party package\n * (not a Node.js built-in, not a relative path)\n */\nfunction isThirdPartyModule(specifier: string): boolean {\n  // Filter out relative paths\n  if (specifier.startsWith('./') || specifier.startsWith('../')) {\n    return false;\n  }\n  // Filter out Node.js built-ins (with or without node: prefix)\n  if (specifier.startsWith('node:')) {\n    return false;\n  }\n  if (nodeBuiltins.has(specifier) || optionalCjsExternal.has(specifier)) {\n    return false;\n  }\n  return true;\n}\n\n/**\n * Find and replace all third-party CJS requires with local entry files\n * Returns the modified source code and the set of third-party modules found\n */\nexport async function replaceThirdPartyCjsRequires(\n  source: string,\n  filePath: string,\n  tsdownExternal: Set<string>,\n): Promise<{ code: string; modules: Set<string> }> {\n  const ast = await parse(filePath, source, {\n    lang: 'js',\n    sourceType: 'module',\n  });\n\n  const thirdPartyModules = new Set<string>();\n\n  // Find all createRequire patterns and their require calls\n  const results = [\n    findCreateRequireInStaticImports(ast),\n    findCreateRequireInGlobalModule(ast),\n  ].filter((result): result is { requireVarName: string; calls: RequireCall[] } => Boolean(result));\n\n  // Collect all third-party require calls\n  const replacements: RequireCall[] = [];\n  for (const { calls } of results) {\n    for (const call of calls) {\n      if (isThirdPartyModule(call.module)) {\n        const parts = call.module.split('/');\n        const moduleName = call.module.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];\n        if (!tsdownExternal.has(moduleName)) {\n          thirdPartyModules.add(call.module);\n          replacements.push(call);\n        }\n      }\n    }\n  }\n\n  // Sort by position descending (process from end to start to avoid offset issues)\n  replacements.sort((a, b) => b.start - a.start);\n\n  // Perform replacements\n  let code = source;\n  for (const { module, start, end } of replacements) {\n    const newSpecifier = `\"./${createModuleEntryFileName(module)}\"`;\n    code = code.slice(0, start) + newSpecifier + code.slice(end);\n  }\n\n  return { code, modules: thirdPartyModules };\n}\n\ninterface RequireCall {\n  module: string;\n  start: number;\n  end: number;\n}\n\n/**\n * Find all calls to a specific require function and return the module specifiers with positions\n */\nfunction findRequireCalls(ast: ParseResult, requireVarName: string): RequireCall[] {\n  const calls: RequireCall[] = [];\n\n  const visitor = new Visitor({\n    CallExpression(node: CallExpression) {\n      // Check if callee is the require variable\n      if (node.callee.type !== 'Identifier' || node.callee.name !== requireVarName) {\n        return;\n      }\n\n      // Extract the first argument (module specifier)\n      if (node.arguments.length === 0) {\n        return;\n      }\n      const arg = node.arguments[0];\n      if (arg.type !== 'Literal') {\n        return;\n      }\n      const value = (arg as { value: unknown; start: number; end: number }).value;\n      if (typeof value === 'string') {\n        calls.push({\n          module: value,\n          start: arg.start,\n          end: arg.end,\n        });\n      }\n    },\n  });\n\n  visitor.visit(ast.program);\n  return calls;\n}\n\n/**\n * Find createRequire from static imports and return the require variable name + all require calls\n * Handles: `import { createRequire } from \"node:module\"` then `const require = createRequire(...)`\n */\nfunction findCreateRequireInStaticImports(\n  ast: ParseResult,\n): { requireVarName: string; calls: RequireCall[] } | undefined {\n  // Find import from 'module' or 'node:module'\n  const importFromModule = ast.module.staticImports.find((imt) => {\n    const { value } = imt.moduleRequest;\n    return value === 'node:module' || value === 'module';\n  });\n  if (!importFromModule) {\n    return;\n  }\n\n  // Find the createRequire import entry\n  const createRequireEntry = importFromModule.entries.find((entry) => {\n    return entry.importName.name === 'createRequire';\n  });\n  if (!createRequireEntry) {\n    return;\n  }\n\n  const createRequireLocalName = createRequireEntry.localName.value;\n\n  // Find the variable that stores the result of createRequire(...)\n  // e.g., `const __require = createRequire(import.meta.url)`\n  let requireVarName: string | undefined;\n\n  const varVisitor = new Visitor({\n    VariableDeclarator(node: VariableDeclarator) {\n      if (!node.init || node.init.type !== 'CallExpression') {\n        return;\n      }\n      const call = node.init;\n      if (call.callee.type === 'Identifier' && call.callee.name === createRequireLocalName) {\n        if (node.id.type === 'Identifier') {\n          requireVarName = node.id.name;\n        }\n      }\n    },\n  });\n  varVisitor.visit(ast.program);\n\n  if (!requireVarName) {\n    return;\n  }\n\n  // Find all calls to the require variable\n  const calls = findRequireCalls(ast, requireVarName);\n\n  return { requireVarName, calls };\n}\n\n// Helper to check if an expression is `process` or `globalThis.process`\nfunction isProcessExpression(expr: Expression): boolean {\n  // Check for `process`\n  if (expr.type === 'Identifier' && expr.name === 'process') {\n    return true;\n  }\n  // Check for `globalThis.process`\n  if (expr.type === 'MemberExpression' && !expr.computed) {\n    const memberExpr = expr as StaticMemberExpression;\n    return (\n      memberExpr.object.type === 'Identifier' &&\n      memberExpr.object.name === 'globalThis' &&\n      memberExpr.property.name === 'process'\n    );\n  }\n  return false;\n}\n\n// Helper to check if a CallExpression is `[process|globalThis.process].getBuiltinModule(\"module\")`\nfunction isGetBuiltinModuleCall(expr: Expression): boolean {\n  if (expr.type !== 'CallExpression') {\n    return false;\n  }\n  const call = expr;\n\n  // Check callee is a member expression with property `getBuiltinModule`\n  if (call.callee.type !== 'MemberExpression' || call.callee.computed) {\n    return false;\n  }\n  const callee = call.callee as StaticMemberExpression;\n  if (callee.property.name !== 'getBuiltinModule') {\n    return false;\n  }\n\n  // Check the object is `process` or `globalThis.process`\n  if (!isProcessExpression(callee.object)) {\n    return false;\n  }\n\n  // Check argument is \"module\" or \"node:module\"\n  if (call.arguments.length === 0) {\n    return false;\n  }\n  const arg = call.arguments[0];\n  if (arg.type !== 'Literal') {\n    return false;\n  }\n  const value = (arg as { value: unknown }).value;\n  return value === 'module' || value === 'node:module';\n}\n\n/**\n * Find createRequire from getBuiltinModule and return the require variable name + all require calls\n * Handles: `const require = globalThis.process.getBuiltinModule(\"module\").createRequire(import.meta.url)`\n * Or: `const require = process.getBuiltinModule(\"module\").createRequire(import.meta.url)`\n */\nfunction findCreateRequireInGlobalModule(\n  ast: ParseResult,\n): { requireVarName: string; calls: RequireCall[] } | undefined {\n  let requireVarName: string | undefined;\n\n  const visitor = new Visitor({\n    VariableDeclarator(node: VariableDeclarator) {\n      if (!node.init || node.init.type !== 'CallExpression') {\n        return;\n      }\n\n      const call = node.init;\n\n      // Check if callee is a MemberExpression with property `createRequire`\n      if (call.callee.type !== 'MemberExpression' || call.callee.computed) {\n        return;\n      }\n      const callee = call.callee as StaticMemberExpression;\n      if (callee.property.name !== 'createRequire') {\n        return;\n      }\n\n      // Check if the object is a getBuiltinModule(\"module\") call\n      if (!isGetBuiltinModuleCall(callee.object)) {\n        return;\n      }\n\n      // Extract variable name\n      if (node.id.type === 'Identifier') {\n        requireVarName = node.id.name;\n      }\n    },\n  });\n\n  visitor.visit(ast.program);\n\n  if (!requireVarName) {\n    return;\n  }\n\n  // Find all calls to the require variable\n  const calls = findRequireCalls(ast, requireVarName);\n\n  return { requireVarName, calls };\n}\n"
  },
  {
    "path": "packages/core/build-support/rewrite-imports.ts",
    "content": "import { type Plugin } from 'rolldown';\n\nimport pkgJson from '../package.json' with { type: 'json' };\n\nexport const RewriteImportsPlugin: Plugin = {\n  name: 'rewrite-imports-for-vite-plus',\n  resolveId: {\n    order: 'pre',\n    handler(id: string) {\n      if (id.startsWith('vite/')) {\n        return { id: id.replace(/^vite\\//, `${pkgJson.name}/`), external: true };\n      }\n      if (id === 'rolldown') {\n        return { id: `${pkgJson.name}/rolldown`, external: true };\n      }\n      if (id.startsWith('rolldown/')) {\n        return {\n          id: id.replace(/^rolldown\\//, `${pkgJson.name}/rolldown/`),\n          external: true,\n        };\n      }\n    },\n  },\n};\n"
  },
  {
    "path": "packages/core/build-support/rewrite-module-specifiers.ts",
    "content": "import { Lang, parse, type Edit, type SgNode } from '@ast-grep/napi';\n\nexport interface ReplacementRule {\n  /** The module specifier pattern to match (string for exact/prefix, RegExp for pattern) */\n  from: string | RegExp;\n  /** The replacement (string or function for dynamic replacement) */\n  to: string | ((match: string) => string);\n}\n\nexport interface RewriteOptions {\n  rules: ReplacementRule[];\n}\n\n/**\n * Get the ast-grep language for a given file path\n */\nfunction getLangForFile(filePath: string): Lang {\n  if (filePath.endsWith('.tsx')) {\n    return Lang.Tsx;\n  }\n  if (\n    filePath.endsWith('.ts') ||\n    filePath.endsWith('.d.ts') ||\n    filePath.endsWith('.mts') ||\n    filePath.endsWith('.d.mts')\n  ) {\n    return Lang.TypeScript;\n  }\n  return Lang.JavaScript;\n}\n\n/**\n * Extract the string content from a string literal node (removes quotes)\n */\nfunction getStringContent(node: SgNode): string {\n  const text = node.text();\n  // Remove surrounding quotes (single, double, or backtick)\n  if (\n    (text.startsWith('\"') && text.endsWith('\"')) ||\n    (text.startsWith(\"'\") && text.endsWith(\"'\")) ||\n    (text.startsWith('`') && text.endsWith('`'))\n  ) {\n    return text.slice(1, -1);\n  }\n  return text;\n}\n\n/**\n * Get the quote character used in a string literal\n */\nfunction getQuoteChar(node: SgNode): string {\n  const text = node.text();\n  return text[0] || '\"';\n}\n\n/**\n * Check if specifier matches the \"from\" pattern\n * Matches exact, subpath (from/...), or file extension (from.xxx)\n */\nfunction matchesFrom(specifier: string, from: string): boolean {\n  if (specifier === from) {\n    return true;\n  }\n  if (!specifier.startsWith(from)) {\n    return false;\n  }\n  // Check the character after the prefix - must be '/', '.', or end of string\n  const nextChar = specifier[from.length];\n  return nextChar === '/' || nextChar === '.';\n}\n\n/**\n * Apply replacement rules to a module specifier\n */\nfunction applyRules(specifier: string, rules: ReplacementRule[]): string | null {\n  for (const rule of rules) {\n    if (typeof rule.from === 'string') {\n      // Exact match or prefix match (e.g., \"vite\" matches \"vite\", \"vite/...\", and \"vite.xxx\")\n      if (matchesFrom(specifier, rule.from)) {\n        if (typeof rule.to === 'function') {\n          return rule.to(specifier);\n        }\n        // Replace the \"from\" prefix with the \"to\" value\n        return rule.to + specifier.slice(rule.from.length);\n      }\n    } else {\n      // RegExp match\n      if (rule.from.test(specifier)) {\n        if (typeof rule.to === 'function') {\n          return rule.to(specifier);\n        }\n        return specifier.replace(rule.from, rule.to);\n      }\n    }\n  }\n  return null;\n}\n\n/**\n * Find all string literal children within a node that could be module specifiers\n */\nfunction findStringLiterals(node: SgNode): SgNode[] {\n  const results: SgNode[] = [];\n  const children = node.children();\n  for (const child of children) {\n    const kind = child.kind();\n    if (kind === 'string' || kind === 'string_literal') {\n      results.push(child);\n    }\n    // Also check nested children (e.g., for call_expression arguments)\n    results.push(...findStringLiterals(child));\n  }\n  return results;\n}\n\n/**\n * Rewrite module specifiers in source code using ast-grep\n */\nexport function rewriteModuleSpecifiers(\n  source: string,\n  filePath: string,\n  options: RewriteOptions,\n): string {\n  const lang = getLangForFile(filePath);\n  const ast = parse(lang, source);\n  const root = ast.root();\n  const edits: Edit[] = [];\n  const processedRanges = new Set<string>();\n\n  // Find all import/export statements, call expressions, and ambient module declarations\n  const nodeKinds = ['import_statement', 'export_statement', 'call_expression'];\n\n  // Add TypeScript-specific kinds for .d.ts files\n  if (lang === Lang.TypeScript || lang === Lang.Tsx) {\n    nodeKinds.push('ambient_declaration'); // For `declare module \"...\"` in .d.ts files\n  }\n\n  for (const kindName of nodeKinds) {\n    const matches = root.findAll({ rule: { kind: kindName } });\n\n    for (const match of matches) {\n      // For call expressions, check if it's require/__require/import()\n      if (kindName === 'call_expression') {\n        const text = match.text();\n        if (\n          !text.startsWith('require(') &&\n          !text.startsWith('__require(') &&\n          !text.startsWith('import(')\n        ) {\n          continue;\n        }\n      }\n\n      // Find string literals within this node\n      const stringNodes = findStringLiterals(match);\n\n      for (const stringNode of stringNodes) {\n        // Deduplicate by range\n        const range = stringNode.range();\n        const rangeKey = `${range.start.index}-${range.end.index}`;\n        if (processedRanges.has(rangeKey)) {\n          continue;\n        }\n        processedRanges.add(rangeKey);\n\n        const content = getStringContent(stringNode);\n        const newContent = applyRules(content, options.rules);\n\n        if (newContent !== null && newContent !== content) {\n          const quote = getQuoteChar(stringNode);\n          edits.push(stringNode.replace(`${quote}${newContent}${quote}`));\n        }\n      }\n    }\n  }\n\n  if (edits.length === 0) {\n    return source;\n  }\n\n  return root.commitEdits(edits);\n}\n\n/**\n * Create replacement rules for rewriting vite imports to a target package\n */\nexport function createViteRewriteRules(targetPackage: string): ReplacementRule[] {\n  return [\n    // \"vite\" -> \"targetPackage\" (exact match and prefix)\n    { from: 'vite', to: targetPackage },\n  ];\n}\n\n/**\n * Create replacement rules for rewriting rolldown imports to a target package\n */\nexport function createRolldownRewriteRules(targetPackage: string): ReplacementRule[] {\n  return [\n    // \"rolldown\" -> \"targetPackage/rolldown\"\n    { from: 'rolldown', to: `${targetPackage}/rolldown` },\n    // \"@rolldown/pluginutils\" -> \"targetPackage/rolldown/pluginutils\"\n    { from: '@rolldown/pluginutils', to: `${targetPackage}/rolldown/pluginutils` },\n  ];\n}\n"
  },
  {
    "path": "packages/core/build.ts",
    "content": "import { existsSync } from 'node:fs';\nimport { copyFile, cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\nimport { dirname, join, parse, resolve, relative } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport { format } from 'oxfmt';\n\n// Convert native path to POSIX format for glob patterns\nfunction toPosixPath(nativePath: string): string {\n  return nativePath.split(path.sep).join(path.posix.sep);\n}\n\nimport { build, type BuildOptions } from 'rolldown';\nimport { dts } from 'rolldown-plugin-dts';\nimport { glob } from 'tinyglobby';\n\nimport { generateLicenseFile } from '../../scripts/generate-license.ts';\nimport { buildCjsDeps } from './build-support/build-cjs-deps';\nimport { replaceThirdPartyCjsRequires } from './build-support/find-create-require';\nimport { RewriteImportsPlugin } from './build-support/rewrite-imports';\nimport {\n  createRolldownRewriteRules,\n  createViteRewriteRules,\n  rewriteModuleSpecifiers,\n  type ReplacementRule,\n} from './build-support/rewrite-module-specifiers';\nimport pkgJson from './package.json' with { type: 'json' };\nimport viteRolldownConfig from './vite-rolldown.config';\n\nconst projectDir = join(fileURLToPath(import.meta.url), '..');\n\nconst rolldownPluginUtilsDir = resolve(\n  projectDir,\n  '..',\n  '..',\n  'rolldown',\n  'packages',\n  'pluginutils',\n);\n\nconst rolldownSourceDir = resolve(projectDir, '..', '..', 'rolldown', 'packages', 'rolldown');\n\nconst rolldownViteSourceDir = resolve(projectDir, '..', '..', 'vite', 'packages', 'vite');\n\nconst tsdownSourceDir = resolve(projectDir, 'node_modules/tsdown');\n\n// Main build orchestration\nawait bundleRolldownPluginutils();\nawait bundleRolldown();\nawait buildVite();\nawait bundleTsdown();\nawait brandTsdown();\nawait bundleVitepress();\ngenerateLicenseFile({\n  title: 'Vite-Plus core license',\n  packageName: 'Vite-Plus',\n  outputPath: join(projectDir, 'LICENSE.md'),\n  coreLicensePath: join(projectDir, '..', '..', 'LICENSE'),\n  bundledPaths: [join(projectDir, 'dist')],\n  resolveFrom: [\n    projectDir,\n    join(projectDir, '..', '..'),\n    join(projectDir, '..', '..', 'rolldown'),\n    join(projectDir, '..', '..', 'vite'),\n  ],\n  extraPackages: [\n    {\n      packageDir: rolldownSourceDir,\n      licensePath: join(projectDir, '..', '..', 'rolldown', 'LICENSE'),\n    },\n    {\n      packageDir: rolldownPluginUtilsDir,\n      licensePath: join(projectDir, '..', '..', 'rolldown', 'LICENSE'),\n    },\n    {\n      packageDir: rolldownViteSourceDir,\n    },\n    {\n      packageDir: tsdownSourceDir,\n    },\n    {\n      packageDir: join(projectDir, '..', '..', 'node_modules', 'vitepress'),\n    },\n  ],\n});\nif (!existsSync(join(projectDir, 'LICENSE.md'))) {\n  throw new Error('LICENSE.md was not generated during build');\n}\nawait mergePackageJson();\nawait syncLicenseFromRoot();\n\nasync function buildVite() {\n  const newViteRolldownConfig = viteRolldownConfig.map((config) => {\n    config.tsconfig = join(projectDir, 'tsconfig.json');\n    config.cwd = projectDir;\n\n    if (Array.isArray(config.external)) {\n      config.external = config.external.filter((external) => {\n        return !(\n          (typeof external === 'string' &&\n            (external === 'picomatch' ||\n              external === 'tinyglobby' ||\n              external === 'fdir' ||\n              external === 'rolldown')) ||\n          external === 'yaml' ||\n          (external instanceof RegExp && (external.test('rolldown/') || external.test('vite/')))\n        );\n      });\n    }\n\n    if (typeof config.output === 'object' && !Array.isArray(config.output)) {\n      config.output.dir = './dist/vite';\n    }\n\n    if (config.platform === 'node') {\n      if (config.resolve) {\n        if (Array.isArray(config.resolve?.conditionNames)) {\n          config.resolve?.conditionNames?.unshift('dev');\n        } else {\n          config.resolve.conditionNames = ['dev'];\n        }\n      } else {\n        config.resolve = {\n          conditionNames: ['dev'],\n        };\n      }\n    }\n\n    if (Array.isArray(config.plugins)) {\n      config.plugins = [\n        // Add RewriteImportsPlugin to handle vite/rolldown import rewrites\n        RewriteImportsPlugin,\n        {\n          name: 'fix-module-runner-dynamic-request-url',\n          transform(_, id, meta) {\n            if (id.endsWith(join('vite', 'src', 'module-runner', 'runner.ts'))) {\n              const { magicString } = meta;\n              if (magicString) {\n                // Fix dynamicRequest to use the server-normalized module URL\n                // (mod.url) instead of the raw URL parameter for relative path\n                // resolution. The raw `url` can be a file:// URL (e.g. from\n                // VitePress's `import(pathToFileURL(entryPath).href)`) which\n                // pathe.resolve cannot handle as an absolute path, producing\n                // malformed paths like \"<cwd>/file:<path>\".\n                // mod.url is always a server-normalized URL (e.g. /@fs/...)\n                // that posixResolve handles correctly.\n                magicString.replace(\n                  `if (dep[0] === '.') {\\n        dep = posixResolve(posixDirname(url), dep)\\n      }`,\n                  `if (dep[0] === '.') {\\n        dep = posixResolve(posixDirname(mod.url), dep)\\n      }`,\n                );\n                return {\n                  code: magicString,\n                };\n              }\n            }\n          },\n        },\n        {\n          name: 'rewrite-static-paths',\n          transform(_, id, meta) {\n            if (id.endsWith(join('vite', 'src', 'node', 'constants.ts'))) {\n              const { magicString } = meta;\n              if (magicString) {\n                magicString.replace(\n                  `export const VITE_PACKAGE_DIR: string = resolve(\n  fileURLToPath(import.meta.url),\n  '../../..',\n)`,\n                  // From 'node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/chunks/const.js' to  'node_modules/@voidzero-dev/vite-plus-core'\n                  `export const VITE_PACKAGE_DIR: string = path.join(fileURLToPath(/** #__KEEP__ */import.meta.url), '..', '..', '..', '..', '..')`,\n                );\n                magicString.replace(\n                  `export const CLIENT_ENTRY: string = resolve(\n  VITE_PACKAGE_DIR,\n  'dist/client/client.mjs',\n)`,\n                  `export const CLIENT_ENTRY = path.join(VITE_PACKAGE_DIR, 'dist/vite/client/client.mjs')`,\n                );\n                magicString.replace(\n                  `export const ENV_ENTRY: string = resolve(\n  VITE_PACKAGE_DIR,\n  'dist/client/env.mjs',\n)`,\n                  `export const ENV_ENTRY = path.join(VITE_PACKAGE_DIR, 'dist/vite/client/env.mjs')`,\n                );\n                magicString.replace(\n                  `const { version } = JSON.parse(\n  readFileSync(new URL('../../package.json', import.meta.url)).toString(),\n)`,\n                  `import { version } from '../../package.json' with { type: 'json' }`,\n                );\n                return {\n                  code: magicString,\n                };\n              }\n            }\n          },\n        },\n        {\n          name: 'suppress-vite-version-only-reporter-line',\n          transform(code, id) {\n            if (!id.endsWith(join('vite', 'src', 'node', 'plugins', 'reporter.ts'))) {\n              return;\n            }\n\n            // Upstream native reporter can emit a redundant standalone \"vite vX.Y.Z\" line.\n            // Filter it at source so snapshots and CLI output remain stable.\n            if (code.includes('VITE_VERSION_ONLY_LINE_RE')) {\n              return;\n            }\n\n            const constLine =\n              'const COMPRESSIBLE_ASSETS_RE = /\\\\.(?:html|json|svg|txt|xml|xhtml|wasm)$/';\n            const logInfoLine =\n              '        logInfo: shouldLogInfo ? (msg) => env.logger.info(msg) : undefined,';\n\n            if (!code.includes(constLine) || !code.includes(logInfoLine)) {\n              return;\n            }\n\n            return {\n              code: code\n                .replace(\n                  constLine,\n                  `${constLine}\\nconst VITE_VERSION_ONLY_LINE_RE = /^vite v\\\\S+$/`,\n                )\n                .replace(\n                  logInfoLine,\n                  `        logInfo: shouldLogInfo\n          ? (msg) => {\n              // Keep transformed/chunk/gzip logs but suppress redundant version-only line.\n              if (VITE_VERSION_ONLY_LINE_RE.test(msg.trim())) {\n                return\n              }\n              env.logger.info(msg)\n            }\n          : undefined,`,\n                ),\n            };\n          },\n        },\n        ...config.plugins.filter((plugin) => {\n          return !(\n            typeof plugin === 'object' &&\n            plugin !== null &&\n            'name' in plugin &&\n            (plugin.name === 'rollup-plugin-license' || plugin.name === 'bundle-limit')\n          );\n        }),\n      ];\n    }\n\n    if (config.experimental) {\n      config.experimental.nativeMagicString = true;\n    } else {\n      config.experimental = {\n        nativeMagicString: true,\n      };\n    }\n\n    return config;\n  });\n\n  await build(newViteRolldownConfig as BuildOptions[]);\n\n  // Copy additional vite files\n\n  await cp(join(rolldownViteSourceDir, 'misc'), join(projectDir, 'dist/vite/misc'), {\n    recursive: true,\n  });\n\n  // Copy and rewrite .d.ts files\n  // Normalize glob pattern to use forward slashes on Windows\n  const dtsFiles = await glob(\n    toPosixPath(join(rolldownViteSourceDir, 'dist', 'node', '**/*.d.ts')),\n    { absolute: true },\n  );\n\n  for (const dtsFile of dtsFiles) {\n    const file = await readFile(dtsFile, 'utf-8');\n    // Normalize paths to use forward slashes for consistent replacement on Windows\n    const relativePath = toPosixPath(dtsFile).replace(\n      toPosixPath(join(rolldownViteSourceDir, 'dist', 'node')),\n      '',\n    );\n    const dstFilePath = join(projectDir, 'dist', 'vite', 'node', relativePath);\n    const rewrittenFile = rewriteModuleSpecifiers(file, dtsFile, {\n      rules: [...createViteRewriteRules(pkgJson.name), ...createRolldownRewriteRules(pkgJson.name)],\n    });\n    await writeFile(dstFilePath, rewrittenFile);\n  }\n\n  // Copy type files\n  // Normalize glob pattern to use forward slashes on Windows\n  const srcTypeFiles = await glob(toPosixPath(join(rolldownViteSourceDir, 'types', '**/*.d.ts')), {\n    absolute: true,\n  });\n\n  await mkdir(join(projectDir, 'dist/vite/types'), { recursive: true });\n\n  for (const srcDtsFile of srcTypeFiles) {\n    const file = await readFile(srcDtsFile, 'utf-8');\n    // Normalize paths to use forward slashes for consistent replacement on Windows\n    const relativePath = toPosixPath(srcDtsFile).replace(\n      toPosixPath(join(rolldownViteSourceDir, 'types')),\n      '',\n    );\n    const dstFilePath = join(projectDir, 'dist', 'vite', 'types', relativePath);\n    const dir = dirname(dstFilePath);\n    if (!existsSync(dir)) {\n      await mkdir(dir, { recursive: true });\n    }\n    const rewrittenFile = rewriteModuleSpecifiers(file, srcDtsFile, {\n      rules: [...createViteRewriteRules(pkgJson.name), ...createRolldownRewriteRules(pkgJson.name)],\n    });\n    await writeFile(dstFilePath, rewrittenFile);\n  }\n\n  await cp(\n    join(rolldownViteSourceDir, 'client.d.ts'),\n    join(projectDir, 'dist', 'vite', 'client.d.ts'),\n  );\n}\n\nasync function bundleRolldownPluginutils() {\n  await mkdir(join(projectDir, 'dist', 'pluginutils'), { recursive: true });\n\n  await cp(join(rolldownPluginUtilsDir, 'dist'), join(projectDir, 'dist', 'pluginutils'), {\n    recursive: true,\n  });\n}\n\nasync function bundleRolldown() {\n  await mkdir(join(projectDir, 'dist/rolldown'), { recursive: true });\n\n  const rolldownFiles = new Set<string>();\n\n  await cp(join(rolldownSourceDir, 'dist'), join(projectDir, 'dist/rolldown'), {\n    recursive: true,\n    filter: async (from, to) => {\n      if ((await stat(from)).isFile()) {\n        rolldownFiles.add(to);\n      }\n      return true;\n    },\n  });\n\n  // Rewrite @rolldown/pluginutils imports in JS and type declaration files\n  for (const file of rolldownFiles) {\n    if (\n      file.endsWith('.mjs') ||\n      file.endsWith('.js') ||\n      file.endsWith('.d.mts') ||\n      file.endsWith('.d.ts')\n    ) {\n      let source = await readFile(file, 'utf-8');\n      const rules: ReplacementRule[] = [...createRolldownRewriteRules(pkgJson.name)];\n      if (process.env.RELEASE_BUILD) {\n        const rolldownBindingVersion = (\n          await import(toPosixPath(relative(projectDir, join(rolldownSourceDir, 'package.json'))), {\n            with: { type: 'json' },\n          })\n        ).default.version;\n        // @rolldown/binding-darwin-arm64 → @voidzero-dev/vite-plus-darwin-arm64/binding\n        source = source.replace(/@rolldown\\/binding-([a-z0-9-]+)/g, 'vite-plus/binding');\n        source = source.replaceAll(`${rolldownBindingVersion}`, pkgJson.version);\n      }\n      const newSource = rewriteModuleSpecifiers(source, file, { rules });\n      await writeFile(file, newSource);\n    }\n  }\n}\n\nasync function bundleTsdown() {\n  await mkdir(join(projectDir, 'dist/tsdown/dist'), { recursive: true });\n\n  const tsdownExternal = Object.keys(pkgJson.peerDependencies);\n\n  const thirdPartyCjsModules = new Set<string>();\n\n  // Re-build tsdown cli\n  await build({\n    input: {\n      run: join(tsdownSourceDir, 'dist/run.mjs'),\n      index: join(tsdownSourceDir, 'dist/index.mjs'),\n    },\n    output: {\n      format: 'esm',\n      cleanDir: true,\n      dir: join(projectDir, 'dist/tsdown'),\n    },\n    platform: 'node',\n    external: (id: string) => tsdownExternal.some((e) => id.startsWith(e)),\n    plugins: [\n      RewriteImportsPlugin,\n      {\n        name: 'find-third-party-cjs-requires',\n        async transform(code, id) {\n          if (id.endsWith('.js') || id.endsWith('.mjs')) {\n            const { code: updatedCode, modules: thirdPartyModules } =\n              await replaceThirdPartyCjsRequires(code, id, new Set(tsdownExternal));\n            for (const module of thirdPartyModules) {\n              thirdPartyCjsModules.add(module);\n            }\n            return { code: updatedCode };\n          }\n        },\n      },\n    ],\n  });\n\n  await buildCjsDeps(thirdPartyCjsModules, join(projectDir, 'dist/tsdown'));\n\n  await build({\n    input: {\n      'index-types': join(tsdownSourceDir, 'dist/index.d.mts'),\n    },\n    output: {\n      format: 'esm',\n      dir: join(projectDir, 'dist/tsdown'),\n    },\n    plugins: [\n      RewriteImportsPlugin,\n      dts({\n        oxc: true,\n        dtsInput: true,\n      }),\n    ],\n  });\n\n  // Copy esm-shims.js to dist/ so tsdown's shims option can resolve it.\n  // tsdown resolves this file via path.resolve(import.meta.dirname, '..', 'esm-shims.js'),\n  // which means it expects the file at dist/esm-shims.js (one level up from dist/tsdown/).\n  await copyFile(join(tsdownSourceDir, 'esm-shims.js'), join(projectDir, 'dist/esm-shims.js'));\n}\n\nasync function brandTsdown() {\n  const tsdownDistDir = join(projectDir, 'dist/tsdown');\n  const buildFiles = await glob(toPosixPath(join(tsdownDistDir, 'build-*.js')), { absolute: true });\n  const mainFiles = await glob(toPosixPath(join(tsdownDistDir, 'main-*.js')), { absolute: true });\n  if (buildFiles.length === 0) {\n    throw new Error('brandTsdown: no build chunk found in dist/tsdown/');\n  }\n  if (mainFiles.length === 0) {\n    throw new Error('brandTsdown: no main chunk found in dist/tsdown/');\n  }\n\n  const search = '\"tsdown <your-file>\"';\n  const replacement = '\"vp pack <your-file>\"';\n  const buildErrorPatches = [\n    {\n      search:\n        'else throw new Error(`${nameLabel} No input files, try \"vp pack <your-file>\" or create src/index.ts`);',\n      replacement:\n        'else throw new Error(`${nameLabel ? `${nameLabel} ` : \"\"}No input files, try \"vp pack <your-file>\" or create src/index.ts`);',\n    },\n    {\n      search:\n        'if (entries.length === 0) throw new Error(`${nameLabel} Cannot find entry: ${JSON.stringify(entry)}`);',\n      replacement:\n        'if (entries.length === 0) throw new Error(`${nameLabel ? `${nameLabel} ` : \"\"}Cannot find entry: ${JSON.stringify(entry)}`);',\n    },\n  ];\n  let patched = false;\n  let buildErrorsPatched = false;\n\n  for (const buildFile of buildFiles) {\n    let content = await readFile(buildFile, 'utf-8');\n    let changed = false;\n    if (!content.includes(search)) {\n      // Keep going to apply other safety patches below.\n    } else {\n      content = content.replace(search, replacement);\n      console.log(`Branded tsdown → vp pack in ${buildFile}`);\n      patched = true;\n      changed = true;\n    }\n\n    for (const { search, replacement } of buildErrorPatches) {\n      if (content.includes(search)) {\n        content = content.replaceAll(search, replacement);\n        buildErrorsPatched = true;\n        changed = true;\n      }\n    }\n\n    if (changed) {\n      await writeFile(buildFile, content, 'utf-8');\n    }\n  }\n\n  if (!patched) {\n    throw new Error(`brandTsdown: pattern ${search} not found in any build chunk`);\n  }\n  if (!buildErrorsPatched) {\n    throw new Error('brandTsdown: build error message patterns not found in any build chunk');\n  }\n\n  const loggerPatches = [\n    {\n      search: 'output(\"warn\", `\\\\n${bgYellow` WARN `} ${message}\\\\n`);',\n      replacement: 'output(\"warn\", `${bold(yellow`warn:`)} ${message}`);',\n    },\n    {\n      search: 'output(\"warn\", `${bgYellow` WARN `} ${message}\\\\n`);',\n      replacement: 'output(\"warn\", `${bold(yellow`warn:`)} ${message}`);',\n    },\n    {\n      search: 'output(\"error\", `\\\\n${bgRed` ERROR `} ${format(msgs)}\\\\n`);',\n      replacement:\n        'output(\"error\", `${bold(red`error:`)} ${format(msgs).replace(/^([A-Za-z]*Error):\\\\s*/, \"\")}`);',\n    },\n    {\n      search: 'output(\"error\", `${bgRed` ERROR `} ${format(msgs)}\\\\n`);',\n      replacement:\n        'output(\"error\", `${bold(red`error:`)} ${format(msgs).replace(/^([A-Za-z]*Error):\\\\s*/, \"\")}`);',\n    },\n    {\n      search: 'output(\"error\", `${bold(red`error:`)} ${format(msgs)}`);',\n      replacement:\n        'output(\"error\", `${bold(red`error:`)} ${format(msgs).replace(/^([A-Za-z]*Error):\\\\s*/, \"\")}`);',\n    },\n  ];\n  let loggerPatched = false;\n\n  for (const mainFile of mainFiles) {\n    let content = await readFile(mainFile, 'utf-8');\n    let changed = false;\n    for (const { search, replacement } of loggerPatches) {\n      if (content.includes(search)) {\n        content = content.replaceAll(search, replacement);\n        changed = true;\n      }\n    }\n    if (!changed) {\n      continue;\n    }\n    await writeFile(mainFile, content, 'utf-8');\n    console.log(`Branded tsdown logger prefixes in ${mainFile}`);\n    loggerPatched = true;\n  }\n\n  if (!loggerPatched) {\n    throw new Error('brandTsdown: logger prefix patterns not found in any main chunk');\n  }\n}\n\n// Actually do nothing now, we will polish it in the future when `vitepress` is ready\nasync function bundleVitepress() {\n  const vitepressSourceDir = resolve(projectDir, 'node_modules/vitepress');\n  const vitepressDestDir = join(projectDir, 'dist/vitepress');\n\n  await mkdir(vitepressDestDir, { recursive: true });\n\n  // Copy dist directory\n  // Normalize glob pattern to use forward slashes on Windows\n  const vitepressDistFiles = await glob(toPosixPath(join(vitepressSourceDir, 'dist', '**/*')), {\n    absolute: true,\n  });\n\n  for (const file of vitepressDistFiles) {\n    const stats = await stat(file);\n    if (!stats.isFile()) {\n      continue;\n    }\n\n    // Normalize paths to use forward slashes for consistent replacement on Windows\n    const relativePath = toPosixPath(file).replace(\n      toPosixPath(join(vitepressSourceDir, 'dist')),\n      '',\n    );\n    const destPath = join(vitepressDestDir, relativePath);\n\n    await mkdir(parse(destPath).dir, { recursive: true });\n\n    // Rewrite vite imports in .js and .mjs files\n    if (\n      file.endsWith('.js') ||\n      file.endsWith('.mjs') ||\n      file.endsWith('.d.mts') ||\n      file.endsWith('.d.ts')\n    ) {\n      const content = await readFile(file, 'utf-8');\n      // Note: For vitepress, 'vite' -> 'pkgJson.name/vite' (vite subpath)\n      const rewrittenContent = rewriteModuleSpecifiers(content, file, {\n        rules: [{ from: 'vite', to: `${pkgJson.name}/vite` }],\n      });\n      await writeFile(destPath, rewrittenContent, 'utf-8');\n    } else {\n      await copyFile(file, destPath);\n    }\n  }\n\n  // Copy top-level .d.ts files\n  const vitepressTypeFiles = ['client.d.ts', 'theme.d.ts', 'theme-without-fonts.d.ts'];\n  for (const typeFile of vitepressTypeFiles) {\n    const sourcePath = join(vitepressSourceDir, typeFile);\n    const destPath = join(vitepressDestDir, typeFile);\n    try {\n      await copyFile(sourcePath, destPath);\n    } catch {\n      // File might not exist, skip\n    }\n  }\n\n  // Copy types directory\n  const vitepressTypesDir = join(vitepressSourceDir, 'types');\n  const vitepressTypesDestDir = join(vitepressDestDir, 'types');\n  await mkdir(vitepressTypesDestDir, { recursive: true });\n\n  // Normalize glob pattern to use forward slashes on Windows\n  const vitepressTypesFiles = await glob(toPosixPath(join(vitepressTypesDir, '**/*')), {\n    absolute: true,\n  });\n\n  for (const file of vitepressTypesFiles) {\n    const stats = await stat(file);\n    if (!stats.isFile()) {\n      continue;\n    }\n\n    // Normalize paths to use forward slashes for consistent replacement on Windows\n    const relativePath = toPosixPath(file).replace(toPosixPath(vitepressTypesDir), '');\n    const destPath = join(vitepressTypesDestDir, relativePath);\n\n    await mkdir(parse(destPath).dir, { recursive: true });\n    await copyFile(file, destPath);\n  }\n}\n\nasync function mergePackageJson() {\n  const tsdownPkgPath = join(tsdownSourceDir, 'package.json');\n  const rolldownPkgPath = join(rolldownSourceDir, 'package.json');\n  const vitePkgPath = join(rolldownViteSourceDir, 'package.json');\n  const destPkgPath = resolve(projectDir, 'package.json');\n\n  const tsdownPkg = JSON.parse(await readFile(tsdownPkgPath, 'utf-8'));\n  const rolldownPkg = JSON.parse(await readFile(rolldownPkgPath, 'utf-8'));\n  const vitePkg = JSON.parse(await readFile(vitePkgPath, 'utf-8'));\n  const destPkg = JSON.parse(await readFile(destPkgPath, 'utf-8'));\n\n  // Merge peerDependencies from tsdown and vite\n  destPkg.peerDependencies = {\n    ...tsdownPkg.peerDependencies,\n    ...vitePkg.peerDependencies,\n  };\n\n  // Merge peerDependenciesMeta from tsdown and vite\n  destPkg.peerDependenciesMeta = {\n    ...tsdownPkg.peerDependenciesMeta,\n    ...vitePkg.peerDependenciesMeta,\n  };\n\n  destPkg.bundledVersions = {\n    ...destPkg.bundledVersions,\n    vite: vitePkg.version,\n    rolldown: rolldownPkg.version,\n    tsdown: tsdownPkg.version,\n  };\n\n  const { code, errors } = await format(destPkgPath, JSON.stringify(destPkg, null, 2) + '\\n', {\n    sortPackageJson: true,\n  });\n  if (errors.length > 0) {\n    for (const error of errors) {\n      console.error(error);\n    }\n    process.exit(1);\n  }\n  await writeFile(destPkgPath, code);\n}\n\nasync function syncLicenseFromRoot() {\n  const rootLicensePath = join(projectDir, '..', '..', 'LICENSE');\n  const packageLicensePath = join(projectDir, 'LICENSE');\n  await copyFile(rootLicensePath, packageLicensePath);\n}\n"
  },
  {
    "path": "packages/core/package.json",
    "content": "{\n  \"name\": \"@voidzero-dev/vite-plus-core\",\n  \"version\": \"0.0.0\",\n  \"description\": \"The Unified Toolchain for the Web\",\n  \"homepage\": \"https://viteplus.dev/guide\",\n  \"bugs\": {\n    \"url\": \"https://github.com/voidzero-dev/vite-plus/issues\"\n  },\n  \"license\": \"MIT\",\n  \"author\": \"VoidZero Inc.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/voidzero-dev/vite-plus.git\",\n    \"directory\": \"packages/core\"\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"type\": \"module\",\n  \"main\": \"./dist/vite/node/index.js\",\n  \"types\": \"./dist/vite/node/index.d.ts\",\n  \"typesVersions\": {\n    \"*\": {\n      \"module-runner\": [\n        \"dist/node/module-runner.d.ts\"\n      ]\n    }\n  },\n  \"imports\": {\n    \"#module-sync-enabled\": {\n      \"module-sync\": \"./dist/vite/misc/true.js\",\n      \"default\": \"./dist/vite/misc/false.js\"\n    },\n    \"#types/*\": \"./dist/vite/types/*.d.ts\",\n    \"#dep-types/*\": \"./dist/vite/types/*.d.ts\"\n  },\n  \"exports\": {\n    \".\": \"./dist/vite/node/index.js\",\n    \"./client\": {\n      \"types\": \"./dist/vite/client.d.ts\"\n    },\n    \"./dist/client/*\": \"./dist/vite/client/*\",\n    \"./internal\": \"./dist/vite/node/internal.js\",\n    \"./module-runner\": \"./dist/vite/node/module-runner.js\",\n    \"./pack\": {\n      \"default\": \"./dist/tsdown/index.js\",\n      \"types\": \"./dist/tsdown/index-types.d.ts\"\n    },\n    \"./package.json\": \"./package.json\",\n    \"./rolldown\": {\n      \"default\": \"./dist/rolldown/index.mjs\",\n      \"types\": \"./dist/rolldown/index.d.mts\"\n    },\n    \"./rolldown/config\": {\n      \"default\": \"./dist/rolldown/config.mjs\",\n      \"types\": \"./dist/rolldown/config.d.mts\"\n    },\n    \"./rolldown/experimental\": {\n      \"default\": \"./dist/rolldown/experimental-index.mjs\",\n      \"types\": \"./dist/rolldown/experimental-index.d.mts\"\n    },\n    \"./rolldown/experimental/runtime-types\": {\n      \"types\": \"./dist/rolldown/experimental-runtime-types.d.ts\"\n    },\n    \"./rolldown/filter\": {\n      \"default\": \"./dist/rolldown/filter-index.mjs\",\n      \"types\": \"./dist/rolldown/filter-index.d.mts\"\n    },\n    \"./rolldown/getLogFilter\": {\n      \"default\": \"./dist/rolldown/get-log-filter.mjs\",\n      \"types\": \"./dist/rolldown/get-log-filter.d.mts\"\n    },\n    \"./rolldown/parallelPlugin\": {\n      \"default\": \"./dist/rolldown/parallel-plugin.mjs\",\n      \"types\": \"./dist/rolldown/parallel-plugin.d.mts\"\n    },\n    \"./rolldown/parseAst\": {\n      \"default\": \"./dist/rolldown/parse-ast-index.mjs\",\n      \"types\": \"./dist/rolldown/parse-ast-index.d.mts\"\n    },\n    \"./rolldown/plugins\": {\n      \"default\": \"./dist/rolldown/plugins-index.mjs\",\n      \"types\": \"./dist/rolldown/plugins-index.d.mts\"\n    },\n    \"./rolldown/pluginutils\": {\n      \"default\": \"./dist/pluginutils/index.js\",\n      \"types\": \"./dist/pluginutils/index.d.ts\"\n    },\n    \"./rolldown/pluginutils/filter\": {\n      \"default\": \"./dist/pluginutils/filter/index.js\",\n      \"types\": \"./dist/pluginutils/filter/index.d.ts\"\n    },\n    \"./rolldown/utils\": {\n      \"default\": \"./dist/rolldown/utils-index.mjs\",\n      \"types\": \"./dist/rolldown/utils-index.d.mts\"\n    },\n    \"./types/*\": {\n      \"types\": \"./dist/vite/types/*\"\n    },\n    \"./types/internal/*\": null\n  },\n  \"scripts\": {\n    \"build\": \"oxnode -C dev ./build.ts\"\n  },\n  \"dependencies\": {\n    \"@oxc-project/runtime\": \"catalog:\",\n    \"@oxc-project/types\": \"catalog:\",\n    \"lightningcss\": \"^1.30.2\",\n    \"postcss\": \"^8.5.6\"\n  },\n  \"devDependencies\": {\n    \"@ast-grep/napi\": \"^0.40.4\",\n    \"@babel/generator\": \"^7.28.5\",\n    \"@babel/parser\": \"^7.28.5\",\n    \"@babel/types\": \"^7.28.5\",\n    \"@oxc-node/cli\": \"catalog:\",\n    \"@oxc-node/core\": \"catalog:\",\n    \"@vitejs/devtools\": \"^0.1.8\",\n    \"es-module-lexer\": \"^1.7.0\",\n    \"hookable\": \"^6.0.1\",\n    \"magic-string\": \"^0.30.21\",\n    \"oxc-parser\": \"catalog:\",\n    \"oxfmt\": \"catalog:\",\n    \"picocolors\": \"^1.1.1\",\n    \"picomatch\": \"^4.0.3\",\n    \"pkg-types\": \"^2.3.0\",\n    \"rolldown\": \"workspace:*\",\n    \"rolldown-plugin-dts\": \"catalog:\",\n    \"rollup\": \"^4.18.0\",\n    \"rollup-plugin-license\": \"^3.6.0\",\n    \"semver\": \"^7.7.3\",\n    \"tinyglobby\": \"^0.2.15\",\n    \"tree-kill\": \"^1.2.2\",\n    \"tsdown\": \"catalog:\",\n    \"vite\": \"workspace:*\"\n  },\n  \"peerDependencies\": {\n    \"@arethetypeswrong/core\": \"^0.18.1\",\n    \"@tsdown/css\": \"0.21.4\",\n    \"@tsdown/exe\": \"0.21.4\",\n    \"@types/node\": \"^20.19.0 || >=22.12.0\",\n    \"@vitejs/devtools\": \"^0.1.0\",\n    \"esbuild\": \"^0.27.0\",\n    \"jiti\": \">=1.21.0\",\n    \"less\": \"^4.0.0\",\n    \"publint\": \"^0.3.0\",\n    \"sass\": \"^1.70.0\",\n    \"sass-embedded\": \"^1.70.0\",\n    \"stylus\": \">=0.54.8\",\n    \"sugarss\": \"^5.0.0\",\n    \"terser\": \"^5.16.0\",\n    \"tsx\": \"^4.8.1\",\n    \"typescript\": \"^5.0.0\",\n    \"unplugin-unused\": \"^0.5.0\",\n    \"yaml\": \"^2.4.2\"\n  },\n  \"peerDependenciesMeta\": {\n    \"@arethetypeswrong/core\": {\n      \"optional\": true\n    },\n    \"@tsdown/css\": {\n      \"optional\": true\n    },\n    \"@tsdown/exe\": {\n      \"optional\": true\n    },\n    \"@vitejs/devtools\": {\n      \"optional\": true\n    },\n    \"publint\": {\n      \"optional\": true\n    },\n    \"typescript\": {\n      \"optional\": true\n    },\n    \"unplugin-unused\": {\n      \"optional\": true\n    },\n    \"@types/node\": {\n      \"optional\": true\n    },\n    \"esbuild\": {\n      \"optional\": true\n    },\n    \"jiti\": {\n      \"optional\": true\n    },\n    \"sass\": {\n      \"optional\": true\n    },\n    \"sass-embedded\": {\n      \"optional\": true\n    },\n    \"stylus\": {\n      \"optional\": true\n    },\n    \"less\": {\n      \"optional\": true\n    },\n    \"sugarss\": {\n      \"optional\": true\n    },\n    \"terser\": {\n      \"optional\": true\n    },\n    \"tsx\": {\n      \"optional\": true\n    },\n    \"yaml\": {\n      \"optional\": true\n    }\n  },\n  \"optionalDependencies\": {\n    \"fsevents\": \"~2.3.3\"\n  },\n  \"engines\": {\n    \"node\": \"^20.19.0 || >=22.12.0\"\n  },\n  \"bundledVersions\": {\n    \"vite\": \"8.0.2\",\n    \"rolldown\": \"1.0.0-rc.11\",\n    \"tsdown\": \"0.21.4\"\n  }\n}\n"
  },
  {
    "path": "packages/core/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"experimentalDecorators\": true\n  },\n  \"files\": [],\n  \"include\": [],\n  \"exclude\": [\"**/*\"]\n}\n"
  },
  {
    "path": "packages/prompts/LICENSE",
    "content": "Forked from https://github.com/bombshell-dev/clack\n\nOriginal License:\n\nMIT License\n\nCopyright (c) Nate Moore\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
  },
  {
    "path": "packages/prompts/package.json",
    "content": "{\n  \"name\": \"@voidzero-dev/vite-plus-prompts\",\n  \"version\": \"0.0.0\",\n  \"description\": \"The Unified Toolchain for the Web\",\n  \"homepage\": \"https://viteplus.dev/guide\",\n  \"bugs\": {\n    \"url\": \"https://github.com/voidzero-dev/vite-plus/issues\"\n  },\n  \"license\": \"MIT\",\n  \"author\": \"VoidZero Inc.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/voidzero-dev/vite-plus.git\",\n    \"directory\": \"packages/prompts\"\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"type\": \"module\",\n  \"main\": \"./dist/index.mjs\",\n  \"types\": \"./dist/index.d.mts\",\n  \"exports\": {\n    \"types\": \"./dist/index.d.mts\",\n    \"default\": \"./dist/index.mjs\"\n  },\n  \"scripts\": {\n    \"build\": \"tsdown\"\n  },\n  \"dependencies\": {\n    \"@clack/core\": \"^1.0.0\",\n    \"picocolors\": \"^1.0.0\",\n    \"sisteransi\": \"^1.0.5\"\n  },\n  \"devDependencies\": {\n    \"fast-string-width\": \"^1.1.0\",\n    \"fast-wrap-ansi\": \"^0.1.3\",\n    \"is-unicode-supported\": \"^1.3.0\",\n    \"tsdown\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "packages/prompts/src/__tests__/__snapshots__/render.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`prompt renderers > renders autocomplete multiselect with pointer markers plus checkboxes 1`] = `\n\"› Search and select\n  Search: type to filter\n    ◻ Alpha\n      Second line\n  › ◼ Beta\n  ↑/↓ to navigate • Space/Tab: select • Enter: confirm • Type: to search\"\n`;\n\nexports[`prompt renderers > renders autocomplete with pointer markers and focused hint 1`] = `\n\"› Search options\n  Search: type to filter\n  › Alpha (recommended)\n    Second line\n    Beta\n  ↑/↓ to select • Enter: confirm • Type: to search\n\"\n`;\n\nexports[`prompt renderers > renders confirm and select-key in pointer style 1`] = `\n\"› Proceed?\n  › Yes /   No\n\n\n---\n› Pick shortcut\n  ›  a  Add item\n     r  Remove item\n\n\"\n`;\n\nexports[`prompt renderers > renders grouped multiselect with marker, tree branch, and checkbox alignment 1`] = `\n\"› Grouped choices\n\n  ◻ Fruits\n    │ ◻ Apple (fresh)\n      Green\n  › └ ◼ Banana\n\n  ◻ Tools\n    └ ◻ Hammer\n\n\"\n`;\n\nexports[`prompt renderers > renders multiselect with cursor marker plus checkbox state 1`] = `\n\"› Choose multiple\n    ◻ Alpha\n      Second line\n  › ◼ Beta\n    ◻ Gamma\n\n\"\n`;\n\nexports[`prompt renderers > renders select with pointer markers and aligned multiline labels 1`] = `\n\"› Choose an option\n  › Alpha: recommended\n    Second line\n    Beta\n\n\"\n`;\n\nexports[`prompt renderers > renders submitted prompts without extra blank lines 1`] = `\n\"◇ Choose multiple\n  Beta\n\n---\n◇ Proceed?\n  Yes\n\n---\n◇ Project name\n  acme-web\n\"\n`;\n"
  },
  {
    "path": "packages/prompts/src/__tests__/render.spec.ts",
    "content": "import { beforeEach, describe, expect, it, vi } from 'vitest';\n\ntype PromptConfig = {\n  render: (this: Record<string, unknown>) => string;\n};\n\nconst captured: {\n  select?: PromptConfig;\n  multiSelect?: PromptConfig;\n  groupMultiSelect?: PromptConfig;\n  autocomplete?: PromptConfig;\n  confirm?: PromptConfig;\n  selectKey?: PromptConfig;\n  text?: PromptConfig;\n  password?: PromptConfig;\n} = {};\n\nclass SelectPrompt<_Value> {\n  constructor(config: PromptConfig) {\n    captured.select = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass MultiSelectPrompt<_Value> {\n  constructor(config: PromptConfig) {\n    captured.multiSelect = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass GroupMultiSelectPrompt<_Value> {\n  constructor(config: PromptConfig) {\n    captured.groupMultiSelect = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass AutocompletePrompt<_Value> {\n  constructor(config: PromptConfig) {\n    captured.autocomplete = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass ConfirmPrompt {\n  constructor(config: PromptConfig) {\n    captured.confirm = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass SelectKeyPrompt<_Value> {\n  constructor(config: PromptConfig) {\n    captured.selectKey = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass TextPrompt {\n  constructor(config: PromptConfig) {\n    captured.text = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nclass PasswordPrompt {\n  constructor(config: PromptConfig) {\n    captured.password = config;\n  }\n\n  prompt() {\n    return Promise.resolve(Symbol('cancel'));\n  }\n}\n\nvi.mock('@clack/core', () => {\n  return {\n    settings: { withGuide: true },\n    wrapTextWithPrefix: (\n      _output: unknown,\n      text: string,\n      firstPrefix: string,\n      nextPrefix = firstPrefix,\n    ) => {\n      return text\n        .split('\\n')\n        .map((line, index) => `${index === 0 ? firstPrefix : nextPrefix}${line}`)\n        .join('\\n');\n    },\n    getColumns: () => 80,\n    getRows: () => 24,\n    SelectPrompt,\n    MultiSelectPrompt,\n    GroupMultiSelectPrompt,\n    AutocompletePrompt,\n    ConfirmPrompt,\n    SelectKeyPrompt,\n    TextPrompt,\n    PasswordPrompt,\n  };\n});\n\n// oxlint-disable-next-line no-control-regex\nconst stripAnsi = (value: string): string => value.replaceAll(/\\x1b\\[[0-9;]*m/gu, '');\nconst normalize = (value: string): string => stripAnsi(value).replaceAll('\\r\\n', '\\n');\n\nconst renderWith = (config: PromptConfig | undefined, ctx: Record<string, unknown>): string => {\n  if (config === undefined) {\n    throw new Error('Prompt config was not captured');\n  }\n  return normalize(config.render.call(ctx));\n};\n\nbeforeEach(() => {\n  captured.select = undefined;\n  captured.multiSelect = undefined;\n  captured.groupMultiSelect = undefined;\n  captured.autocomplete = undefined;\n  captured.confirm = undefined;\n  captured.selectKey = undefined;\n  captured.text = undefined;\n  captured.password = undefined;\n});\n\ndescribe('prompt renderers', () => {\n  it('renders select with pointer markers and aligned multiline labels', async () => {\n    const { select } = await import('../select.js');\n    const options = [\n      { value: 'alpha', label: 'Alpha\\nSecond line', hint: 'recommended' },\n      { value: 'beta', label: 'Beta' },\n    ];\n    void select({\n      message: 'Choose an option',\n      options,\n    });\n    expect(\n      renderWith(captured.select, {\n        state: 'active',\n        options,\n        cursor: 0,\n      }),\n    ).toMatchSnapshot();\n  });\n\n  it('renders multiselect with cursor marker plus checkbox state', async () => {\n    const { multiselect } = await import('../multi-select.js');\n    const options = [\n      { value: 'alpha', label: 'Alpha\\nSecond line', hint: 'recommended' },\n      { value: 'beta', label: 'Beta' },\n      { value: 'gamma', label: 'Gamma' },\n    ];\n    void multiselect({\n      message: 'Choose multiple',\n      options,\n    });\n    expect(\n      renderWith(captured.multiSelect, {\n        state: 'active',\n        options,\n        cursor: 1,\n        value: ['beta'],\n      }),\n    ).toMatchSnapshot();\n  });\n\n  it('renders grouped multiselect with marker, tree branch, and checkbox alignment', async () => {\n    const { groupMultiselect } = await import('../group-multi-select.js');\n    void groupMultiselect({\n      message: 'Grouped choices',\n      options: {\n        Fruits: [\n          { value: 'apple', label: 'Apple\\nGreen', hint: 'fresh' },\n          { value: 'banana', label: 'Banana' },\n        ],\n        Tools: [{ value: 'hammer', label: 'Hammer' }],\n      },\n      selectableGroups: true,\n      groupSpacing: 1,\n    });\n    const renderedOptions = [\n      { value: 'Fruits', label: 'Fruits', group: true },\n      { value: 'apple', label: 'Apple\\nGreen', hint: 'fresh', group: 'Fruits' },\n      { value: 'banana', label: 'Banana', group: 'Fruits' },\n      { value: 'Tools', label: 'Tools', group: true },\n      { value: 'hammer', label: 'Hammer', group: 'Tools' },\n    ];\n    expect(\n      renderWith(captured.groupMultiSelect, {\n        state: 'active',\n        options: renderedOptions,\n        cursor: 2,\n        value: ['banana'],\n        isGroupSelected: () => false,\n      }),\n    ).toMatchSnapshot();\n  });\n\n  it('renders autocomplete with pointer markers and focused hint', async () => {\n    const { autocomplete } = await import('../autocomplete.js');\n    const options = [\n      { value: 'alpha', label: 'Alpha\\nSecond line', hint: 'recommended' },\n      { value: 'beta', label: 'Beta' },\n    ];\n    void autocomplete({\n      message: 'Search options',\n      options,\n      placeholder: 'type to filter',\n    });\n    expect(\n      renderWith(captured.autocomplete, {\n        state: 'active',\n        options,\n        filteredOptions: options,\n        selectedValues: [],\n        focusedValue: 'alpha',\n        userInput: '',\n        userInputWithCursor: '|',\n        cursor: 0,\n        isNavigating: true,\n      }),\n    ).toMatchSnapshot();\n  });\n\n  it('renders autocomplete multiselect with pointer markers plus checkboxes', async () => {\n    const { autocompleteMultiselect } = await import('../autocomplete.js');\n    const options = [\n      { value: 'alpha', label: 'Alpha\\nSecond line', hint: 'recommended' },\n      { value: 'beta', label: 'Beta' },\n    ];\n    void autocompleteMultiselect({\n      message: 'Search and select',\n      options,\n      placeholder: 'type to filter',\n    });\n    expect(\n      renderWith(captured.autocomplete, {\n        state: 'active',\n        options,\n        filteredOptions: options,\n        selectedValues: ['beta'],\n        focusedValue: 'beta',\n        userInput: '',\n        userInputWithCursor: '|',\n        cursor: 1,\n        isNavigating: true,\n      }),\n    ).toMatchSnapshot();\n  });\n\n  it('renders confirm and select-key in pointer style', async () => {\n    const [{ confirm }, { selectKey }] = await Promise.all([\n      import('../confirm.js'),\n      import('../select-key.js'),\n    ]);\n    void confirm({ message: 'Proceed?' });\n    const confirmOutput = renderWith(captured.confirm, {\n      state: 'active',\n      value: true,\n    });\n\n    const keyOptions = [\n      { value: 'a', label: 'Add item' },\n      { value: 'r', label: 'Remove item' },\n    ];\n    void selectKey({\n      message: 'Pick shortcut',\n      options: keyOptions,\n    });\n    const selectKeyOutput = renderWith(captured.selectKey, {\n      state: 'active',\n      options: keyOptions,\n      cursor: 0,\n    });\n\n    expect(`${confirmOutput}\\n---\\n${selectKeyOutput}`).toMatchSnapshot();\n  });\n\n  it('renders submitted prompts without extra blank lines', async () => {\n    const [{ multiselect }, { confirm }, { text }] = await Promise.all([\n      import('../multi-select.js'),\n      import('../confirm.js'),\n      import('../text.js'),\n    ]);\n\n    const multiOptions = [\n      { value: 'alpha', label: 'Alpha' },\n      { value: 'beta', label: 'Beta' },\n    ];\n    void multiselect({\n      message: 'Choose multiple',\n      options: multiOptions,\n    });\n    const multiselectOutput = renderWith(captured.multiSelect, {\n      state: 'submit',\n      options: multiOptions,\n      value: ['beta'],\n    });\n\n    void confirm({ message: 'Proceed?' });\n    const confirmOutput = renderWith(captured.confirm, {\n      state: 'submit',\n      value: true,\n    });\n\n    void text({ message: 'Project name' });\n    const textOutput = renderWith(captured.text, {\n      state: 'submit',\n      value: 'acme-web',\n    });\n\n    expect(`${multiselectOutput}\\n---\\n${confirmOutput}\\n---\\n${textOutput}`).toMatchSnapshot();\n  });\n});\n"
  },
  {
    "path": "packages/prompts/src/autocomplete.ts",
    "content": "import { AutocompletePrompt } from '@clack/core';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_CHECKBOX_INACTIVE,\n  S_CHECKBOX_SELECTED,\n  S_POINTER_ACTIVE,\n  S_POINTER_INACTIVE,\n  symbol,\n} from './common.js';\nimport { limitOptions } from './limit-options.js';\nimport type { Option } from './select.js';\n\nfunction getLabel<T>(option: Option<T>) {\n  return option.label ?? String(option.value ?? '');\n}\n\nfunction getFilteredOption<T>(searchText: string, option: Option<T>): boolean {\n  if (!searchText) {\n    return true;\n  }\n  const label = (option.label ?? String(option.value ?? '')).toLowerCase();\n  const hint = (option.hint ?? '').toLowerCase();\n  const value = String(option.value).toLowerCase();\n  const term = searchText.toLowerCase();\n\n  return label.includes(term) || hint.includes(term) || value.includes(term);\n}\n\nfunction getSelectedOptions<T>(values: T[], options: Option<T>[]): Option<T>[] {\n  const results: Option<T>[] = [];\n\n  for (const option of options) {\n    if (values.includes(option.value)) {\n      results.push(option);\n    }\n  }\n\n  return results;\n}\n\nconst withMarker = (\n  marker: string,\n  label: string,\n  format: (text: string) => string,\n  firstLineSuffix = '',\n) => {\n  const lines = label.split('\\n');\n  if (lines.length === 1) {\n    return `${marker} ${format(lines[0])}${firstLineSuffix}`;\n  }\n  const [firstLine, ...rest] = lines;\n  return [\n    `${marker} ${format(firstLine)}${firstLineSuffix}`,\n    ...rest.map((line) => `${S_POINTER_INACTIVE} ${format(line)}`),\n  ].join('\\n');\n};\n\nconst withMarkerAndIndicator = (\n  marker: string,\n  indicator: string,\n  indicatorWidth: number,\n  label: string,\n  format: (text: string) => string,\n  firstLineSuffix = '',\n) => {\n  const lines = label.split('\\n');\n  const continuationPrefix = `${S_POINTER_INACTIVE} ${' '.repeat(indicatorWidth)} `;\n  if (lines.length === 1) {\n    return `${marker} ${indicator} ${format(lines[0])}${firstLineSuffix}`;\n  }\n  const [firstLine, ...rest] = lines;\n  return [\n    `${marker} ${indicator} ${format(firstLine)}${firstLineSuffix}`,\n    ...rest.map((line) => `${continuationPrefix}${format(line)}`),\n  ].join('\\n');\n};\n\ninterface AutocompleteSharedOptions<Value> extends CommonOptions {\n  /**\n   * The message to display to the user.\n   */\n  message: string;\n  /**\n   * Available options for the autocomplete prompt.\n   */\n  options: Option<Value>[] | ((this: AutocompletePrompt<Option<Value>>) => Option<Value>[]);\n  /**\n   * Maximum number of items to display at once.\n   */\n  maxItems?: number;\n  /**\n   * Placeholder text to display when no input is provided.\n   */\n  placeholder?: string;\n  /**\n   * Validates the value\n   */\n  validate?: (value: Value | Value[] | undefined) => string | Error | undefined;\n  /**\n   * Custom filter function to match options against search input.\n   * If not provided, a default filter that matches label, hint, and value is used.\n   */\n  filter?: (search: string, option: Option<Value>) => boolean;\n}\n\nexport interface AutocompleteOptions<Value> extends AutocompleteSharedOptions<Value> {\n  /**\n   * The initial selected value.\n   */\n  initialValue?: Value;\n  /**\n   * The initial user input\n   */\n  initialUserInput?: string;\n}\n\nexport const autocomplete = <Value>(opts: AutocompleteOptions<Value>) => {\n  const prompt = new AutocompletePrompt({\n    options: opts.options,\n    initialValue: opts.initialValue ? [opts.initialValue] : undefined,\n    initialUserInput: opts.initialUserInput,\n    filter:\n      opts.filter ??\n      ((search: string, opt: Option<Value>) => {\n        return getFilteredOption(search, opt);\n      }),\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    validate: opts.validate,\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      // Title and message display\n      const headings = hasGuide\n        ? [color.gray(S_BAR), `${symbol(this.state)} ${opts.message}`]\n        : [`${symbol(this.state)} ${opts.message}`];\n      const userInput = this.userInput;\n      const options = this.options;\n      const placeholder = opts.placeholder;\n      const showPlaceholder = userInput === '' && placeholder !== undefined;\n\n      // Handle different states\n      switch (this.state) {\n        case 'submit': {\n          // Show selected value\n          const selected = getSelectedOptions(this.selectedValues, options);\n          const label = selected.length > 0 ? color.dim(selected.map(getLabel).join(', ')) : '';\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return `${headings.join('\\n')}\\n${submitPrefix}${label}\\n\\n`;\n        }\n\n        case 'cancel': {\n          const userInputText = userInput ? color.strikethrough(color.dim(userInput)) : '';\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return `${headings.join('\\n')}\\n${cancelPrefix}${userInputText}\\n\\n`;\n        }\n\n        default: {\n          const barColor = this.state === 'error' ? color.yellow : color.blue;\n          const guidePrefix = hasGuide ? `${barColor(S_BAR)} ` : nestedPrefix;\n          const guidePrefixEnd = hasGuide ? barColor(S_BAR_END) : '';\n          // Display cursor position - show plain text in navigation mode\n          let searchText = '';\n          if (this.isNavigating || showPlaceholder) {\n            const searchTextValue = showPlaceholder ? placeholder : userInput;\n            searchText = searchTextValue !== '' ? ` ${color.dim(searchTextValue)}` : '';\n          } else {\n            searchText = ` ${this.userInputWithCursor}`;\n          }\n\n          // Show match count if filtered\n          const matches =\n            this.filteredOptions.length !== options.length\n              ? color.dim(\n                  ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`,\n                )\n              : '';\n\n          // No matches message\n          const noResults =\n            this.filteredOptions.length === 0 && userInput\n              ? [`${guidePrefix}${color.yellow('No matches found')}`]\n              : [];\n\n          const validationError =\n            this.state === 'error' ? [`${guidePrefix}${color.yellow(this.error)}`] : [];\n\n          if (hasGuide) {\n            headings.push(guidePrefix.trimEnd());\n          }\n          headings.push(\n            `${guidePrefix}${color.dim('Search:')}${searchText}${matches}`,\n            ...noResults,\n            ...validationError,\n          );\n\n          // Show instructions\n          const instructions = [\n            `${color.dim('↑/↓')} to select`,\n            `${color.dim('Enter:')} confirm`,\n            `${color.dim('Type:')} to search`,\n          ];\n\n          const footers = [`${guidePrefix}${instructions.join(' • ')}`, guidePrefixEnd];\n\n          // Render options with selection\n          const displayOptions =\n            this.filteredOptions.length === 0\n              ? []\n              : limitOptions({\n                  cursor: this.cursor,\n                  options: this.filteredOptions,\n                  columnPadding: hasGuide ? 2 : 2,\n                  rowPadding: headings.length + footers.length,\n                  style: (option, active) => {\n                    const label = getLabel(option);\n                    const hint =\n                      option.hint && option.value === this.focusedValue\n                        ? color.gray(` (${option.hint})`)\n                        : '';\n\n                    return active\n                      ? withMarker(\n                          color.blue(S_POINTER_ACTIVE),\n                          label,\n                          (text) => color.blue(color.bold(text)),\n                          hint,\n                        )\n                      : withMarker(color.dim(S_POINTER_INACTIVE), label, color.dim, hint);\n                  },\n                  maxItems: opts.maxItems,\n                  output: opts.output,\n                });\n\n          // Return the formatted prompt\n          return [\n            ...headings,\n            ...displayOptions.map((option) => `${guidePrefix}${option}`),\n            ...footers,\n          ].join('\\n');\n        }\n      }\n    },\n  });\n\n  // Return the result or cancel symbol\n  return prompt.prompt() as Promise<Value | symbol>;\n};\n\n// Type definition for the autocompleteMultiselect component\nexport interface AutocompleteMultiSelectOptions<Value> extends AutocompleteSharedOptions<Value> {\n  /**\n   * The initial selected values\n   */\n  initialValues?: Value[];\n  /**\n   * If true, at least one option must be selected\n   */\n  required?: boolean;\n}\n\n/**\n * Integrated autocomplete multiselect - combines type-ahead filtering with multiselect in one UI\n */\nexport const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOptions<Value>) => {\n  const formatOption = (\n    option: Option<Value>,\n    active: boolean,\n    selectedValues: Value[],\n    focusedValue: Value | undefined,\n  ) => {\n    const isSelected = selectedValues.includes(option.value);\n    const label = option.label ?? String(option.value ?? '');\n    const hint =\n      option.hint && focusedValue !== undefined && option.value === focusedValue\n        ? color.gray(` (${option.hint})`)\n        : '';\n    const checkboxRaw = isSelected ? S_CHECKBOX_SELECTED : S_CHECKBOX_INACTIVE;\n    const checkbox = isSelected ? color.blue(checkboxRaw) : color.dim(checkboxRaw);\n    const marker = active ? color.blue(S_POINTER_ACTIVE) : color.dim(S_POINTER_INACTIVE);\n    return withMarkerAndIndicator(\n      marker,\n      checkbox,\n      checkboxRaw.length,\n      label,\n      active ? (text) => color.blue(color.bold(text)) : color.dim,\n      hint,\n    );\n  };\n\n  // Create text prompt which we'll use as foundation\n  const prompt = new AutocompletePrompt<Option<Value>>({\n    options: opts.options,\n    multiple: true,\n    filter:\n      opts.filter ??\n      ((search, opt) => {\n        return getFilteredOption(search, opt);\n      }),\n    validate: () => {\n      if (opts.required && prompt.selectedValues.length === 0) {\n        return 'Please select at least one item';\n      }\n      return undefined;\n    },\n    initialValue: opts.initialValues,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      // Title and symbol\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${symbol(this.state)} ${opts.message}\\n`;\n\n      // Selection counter\n      const userInput = this.userInput;\n      const placeholder = opts.placeholder;\n      const showPlaceholder = userInput === '' && placeholder !== undefined;\n\n      // Search input display\n      const searchText =\n        this.isNavigating || showPlaceholder\n          ? color.dim(showPlaceholder ? placeholder : userInput) // Just show plain text when in navigation mode\n          : this.userInputWithCursor;\n\n      const options = this.options;\n\n      const matches =\n        this.filteredOptions.length !== options.length\n          ? color.dim(\n              ` (${this.filteredOptions.length} match${this.filteredOptions.length === 1 ? '' : 'es'})`,\n            )\n          : '';\n\n      // Render prompt state\n      switch (this.state) {\n        case 'submit': {\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : '';\n          const finalPrefix = hasGuide ? submitPrefix : nestedPrefix;\n          return `${title}${finalPrefix}${color.dim(`${this.selectedValues.length} items selected`)}\\n\\n`;\n        }\n        case 'cancel': {\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : '';\n          const finalPrefix = hasGuide ? cancelPrefix : nestedPrefix;\n          return `${title}${finalPrefix}${color.strikethrough(color.dim(userInput))}\\n\\n`;\n        }\n        default: {\n          const barColor = this.state === 'error' ? color.yellow : color.blue;\n          const prefix = hasGuide ? `${barColor(S_BAR)} ` : nestedPrefix;\n          const footerEnd = hasGuide ? [barColor(S_BAR_END)] : [];\n          // Instructions\n          const instructions = [\n            `${color.dim('↑/↓')} to navigate`,\n            `${color.dim(this.isNavigating ? 'Space/Tab:' : 'Tab:')} select`,\n            `${color.dim('Enter:')} confirm`,\n            `${color.dim('Type:')} to search`,\n          ];\n\n          // No results message\n          const noResults =\n            this.filteredOptions.length === 0 && userInput\n              ? [`${prefix}${color.yellow('No matches found')}`]\n              : [];\n\n          const errorMessage =\n            this.state === 'error' ? [`${prefix}${color.yellow(this.error)}`] : [];\n\n          // Calculate header and footer line counts for rowPadding\n          const headerLines = [\n            ...title.trimEnd().split('\\n'),\n            `${prefix}${color.dim('Search:')} ${searchText}${matches}`,\n            ...noResults,\n            ...errorMessage,\n          ];\n          const footerLines = [`${prefix}${instructions.join(' • ')}`, ...footerEnd];\n\n          // Get limited options for display\n          const displayOptions = limitOptions({\n            cursor: this.cursor,\n            options: this.filteredOptions,\n            style: (option, active) =>\n              formatOption(option, active, this.selectedValues, this.focusedValue),\n            maxItems: opts.maxItems,\n            output: opts.output,\n            rowPadding: headerLines.length + footerLines.length,\n          });\n\n          // Build the prompt display\n          return [\n            ...headerLines,\n            ...displayOptions.map((option) => `${prefix}${option}`),\n            ...footerLines,\n          ].join('\\n');\n        }\n      }\n    },\n  });\n\n  // Return the result or cancel symbol\n  return prompt.prompt() as Promise<Value[] | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/box.ts",
    "content": "import type { Writable } from 'node:stream';\n\nimport { getColumns } from '@clack/core';\nimport stringWidth from 'fast-string-width';\nimport { wrapAnsi } from 'fast-wrap-ansi';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_BAR_END_RIGHT,\n  S_BAR_H,\n  S_BAR_START,\n  S_BAR_START_RIGHT,\n  S_CORNER_BOTTOM_LEFT,\n  S_CORNER_BOTTOM_RIGHT,\n  S_CORNER_TOP_LEFT,\n  S_CORNER_TOP_RIGHT,\n} from './common.js';\n\nexport type BoxAlignment = 'left' | 'center' | 'right';\n\ntype BoxSymbols = [topLeft: string, topRight: string, bottomLeft: string, bottomRight: string];\n\nconst roundedSymbols: BoxSymbols = [\n  S_CORNER_TOP_LEFT,\n  S_CORNER_TOP_RIGHT,\n  S_CORNER_BOTTOM_LEFT,\n  S_CORNER_BOTTOM_RIGHT,\n];\nconst squareSymbols: BoxSymbols = [S_BAR_START, S_BAR_START_RIGHT, S_BAR_END, S_BAR_END_RIGHT];\n\nexport interface BoxOptions extends CommonOptions {\n  contentAlign?: BoxAlignment;\n  titleAlign?: BoxAlignment;\n  width?: number | 'auto';\n  titlePadding?: number;\n  contentPadding?: number;\n  rounded?: boolean;\n  formatBorder?: (text: string) => string;\n}\n\nfunction getPaddingForLine(\n  lineLength: number,\n  innerWidth: number,\n  padding: number,\n  contentAlign: BoxAlignment | undefined,\n): [number, number] {\n  let leftPadding = padding;\n  let rightPadding = padding;\n  if (contentAlign === 'center') {\n    leftPadding = Math.floor((innerWidth - lineLength) / 2);\n  } else if (contentAlign === 'right') {\n    leftPadding = innerWidth - lineLength - padding;\n  }\n\n  rightPadding = innerWidth - leftPadding - lineLength;\n\n  return [leftPadding, rightPadding];\n}\n\nconst defaultFormatBorder = (text: string) => text;\n\nexport const box = (message = '', title = '', opts?: BoxOptions) => {\n  const output: Writable = opts?.output ?? process.stdout;\n  const columns = getColumns(output);\n  const borderWidth = 1;\n  const borderTotalWidth = borderWidth * 2;\n  const titlePadding = opts?.titlePadding ?? 1;\n  const contentPadding = opts?.contentPadding ?? 2;\n  const width = opts?.width === undefined || opts.width === 'auto' ? 1 : Math.min(1, opts.width);\n  const hasGuide = opts?.withGuide ?? false;\n  const linePrefix = !hasGuide ? '' : `${S_BAR} `;\n  const formatBorder = opts?.formatBorder ?? defaultFormatBorder;\n  const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder);\n  const hSymbol = formatBorder(S_BAR_H);\n  const vSymbol = formatBorder(S_BAR);\n  const linePrefixWidth = stringWidth(linePrefix);\n  const titleWidth = stringWidth(title);\n  const maxBoxWidth = columns - linePrefixWidth;\n  let boxWidth = Math.floor(columns * width) - linePrefixWidth;\n  if (opts?.width === 'auto') {\n    const lines = message.split('\\n');\n    let longestLine = titleWidth + titlePadding * 2;\n    for (const line of lines) {\n      const lineWithPadding = stringWidth(line) + contentPadding * 2;\n      if (lineWithPadding > longestLine) {\n        longestLine = lineWithPadding;\n      }\n    }\n    const longestLineWidth = longestLine + borderTotalWidth;\n    if (longestLineWidth < boxWidth) {\n      boxWidth = longestLineWidth;\n    }\n  }\n  if (boxWidth % 2 !== 0) {\n    if (boxWidth < maxBoxWidth) {\n      boxWidth++;\n    } else {\n      boxWidth--;\n    }\n  }\n  const innerWidth = boxWidth - borderTotalWidth;\n  const maxTitleLength = innerWidth - titlePadding * 2;\n  const truncatedTitle =\n    titleWidth > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title;\n  const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine(\n    stringWidth(truncatedTitle),\n    innerWidth,\n    titlePadding,\n    opts?.titleAlign,\n  );\n  const wrappedMessage = wrapAnsi(message, innerWidth - contentPadding * 2, {\n    hard: true,\n    trim: false,\n  });\n  output.write(\n    `${linePrefix}${symbols[0]}${hSymbol.repeat(titlePaddingLeft)}${truncatedTitle}${hSymbol.repeat(titlePaddingRight)}${symbols[1]}\\n`,\n  );\n  const wrappedLines = wrappedMessage.split('\\n');\n  for (const line of wrappedLines) {\n    const [leftLinePadding, rightLinePadding] = getPaddingForLine(\n      stringWidth(line),\n      innerWidth,\n      contentPadding,\n      opts?.contentAlign,\n    );\n    output.write(\n      `${linePrefix}${vSymbol}${' '.repeat(leftLinePadding)}${line}${' '.repeat(rightLinePadding)}${vSymbol}\\n`,\n    );\n  }\n  output.write(`${linePrefix}${symbols[2]}${hSymbol.repeat(innerWidth)}${symbols[3]}\\n`);\n};\n"
  },
  {
    "path": "packages/prompts/src/common.ts",
    "content": "import type { Readable, Writable } from 'node:stream';\n\nimport type { State } from '@clack/core';\nimport isUnicodeSupported from 'is-unicode-supported';\nimport color from 'picocolors';\n\nexport const unicode = isUnicodeSupported();\nexport const isCI = (): boolean => process.env.CI === 'true';\nexport const isTTY = (output: Writable): boolean => {\n  return (output as Writable & { isTTY?: boolean }).isTTY === true;\n};\nexport const unicodeOr = (c: string, fallback: string) => (unicode ? c : fallback);\nexport const S_POINTER_ACTIVE = unicodeOr('›', '>');\nexport const S_POINTER_INACTIVE = ' ';\nexport const S_STEP_ACTIVE = S_POINTER_ACTIVE;\nexport const S_STEP_CANCEL = unicodeOr('■', 'x');\nexport const S_STEP_ERROR = unicodeOr('▲', 'x');\nexport const S_STEP_SUBMIT = unicodeOr('◇', 'o');\n\nexport const S_BAR_START = unicodeOr('┌', 'T');\nexport const S_BAR = unicodeOr('│', '|');\nexport const S_BAR_END = unicodeOr('└', '—');\nexport const S_BAR_START_RIGHT = unicodeOr('┐', 'T');\nexport const S_BAR_END_RIGHT = unicodeOr('┘', '—');\n\nexport const S_RADIO_ACTIVE = S_POINTER_ACTIVE;\nexport const S_RADIO_INACTIVE = S_POINTER_INACTIVE;\nexport const S_CHECKBOX_ACTIVE = unicodeOr('◻', '[•]');\nexport const S_CHECKBOX_SELECTED = unicodeOr('◼', '[+]');\nexport const S_CHECKBOX_INACTIVE = unicodeOr('◻', '[ ]');\nexport const S_PASSWORD_MASK = unicodeOr('▪', '•');\n\nexport const S_BAR_H = unicodeOr('─', '-');\nexport const S_CORNER_TOP_RIGHT = unicodeOr('╮', '+');\nexport const S_CONNECT_LEFT = unicodeOr('├', '+');\nexport const S_CORNER_BOTTOM_RIGHT = unicodeOr('╯', '+');\nexport const S_CORNER_BOTTOM_LEFT = unicodeOr('╰', '+');\nexport const S_CORNER_TOP_LEFT = unicodeOr('╭', '+');\n\nexport const S_INFO = unicodeOr('●', '•');\nexport const S_SUCCESS = unicodeOr('◆', '*');\nexport const S_WARN = unicodeOr('▲', '!');\nexport const S_ERROR = unicodeOr('■', 'x');\n\nexport const completeColor = (value: string) => color.gray(value);\n\nexport const symbol = (state: State) => {\n  switch (state) {\n    case 'initial':\n    case 'active':\n      return color.blue(S_STEP_ACTIVE);\n    case 'cancel':\n      return color.red(S_STEP_CANCEL);\n    case 'error':\n      return color.yellow(S_STEP_ERROR);\n    case 'submit':\n      return completeColor(S_STEP_SUBMIT);\n  }\n};\n\nexport const symbolBar = (state: State) => {\n  switch (state) {\n    case 'initial':\n    case 'active':\n      return color.blue(S_BAR);\n    case 'cancel':\n      return color.red(S_BAR);\n    case 'error':\n      return color.yellow(S_BAR);\n    case 'submit':\n      return completeColor(S_BAR);\n  }\n};\n\nexport interface CommonOptions {\n  input?: Readable;\n  output?: Writable;\n  signal?: AbortSignal;\n  withGuide?: boolean;\n}\n"
  },
  {
    "path": "packages/prompts/src/confirm.ts",
    "content": "import { ConfirmPrompt } from '@clack/core';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_POINTER_ACTIVE,\n  S_POINTER_INACTIVE,\n  symbol,\n} from './common.js';\n\nexport interface ConfirmOptions extends CommonOptions {\n  message: string;\n  active?: string;\n  inactive?: string;\n  initialValue?: boolean;\n  vertical?: boolean;\n}\nexport const confirm = (opts: ConfirmOptions) => {\n  const active = opts.active ?? 'Yes';\n  const inactive = opts.inactive ?? 'No';\n  return new ConfirmPrompt({\n    active,\n    inactive,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    initialValue: opts.initialValue ?? true,\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${symbol(this.state)} ${opts.message}\\n`;\n      const value = this.value ? active : inactive;\n\n      switch (this.state) {\n        case 'submit': {\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return `${title}${submitPrefix}${color.dim(value)}\\n`;\n        }\n        case 'cancel': {\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return `${title}${cancelPrefix}${color.strikethrough(\n            color.dim(value),\n          )}${hasGuide ? `\\n${color.gray(S_BAR)}` : ''}\\n`;\n        }\n        default: {\n          const defaultPrefix = hasGuide ? `${color.blue(S_BAR)} ` : nestedPrefix;\n          const defaultPrefixEnd = hasGuide ? color.blue(S_BAR_END) : '';\n          return `${title}${defaultPrefix}${\n            this.value\n              ? `${color.blue(S_POINTER_ACTIVE)} ${color.bold(active)}`\n              : `${color.dim(S_POINTER_INACTIVE)} ${color.dim(active)}`\n          }${\n            opts.vertical\n              ? hasGuide\n                ? `\\n${color.blue(S_BAR)} `\n                : `\\n${nestedPrefix}`\n              : ` ${color.dim('/')} `\n          }${\n            !this.value\n              ? `${color.blue(S_POINTER_ACTIVE)} ${color.bold(inactive)}`\n              : `${color.dim(S_POINTER_INACTIVE)} ${color.dim(inactive)}`\n          }\\n${defaultPrefixEnd}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<boolean | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/group-multi-select.ts",
    "content": "import { GroupMultiSelectPrompt } from '@clack/core';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_CHECKBOX_ACTIVE,\n  S_CHECKBOX_INACTIVE,\n  S_CHECKBOX_SELECTED,\n  S_POINTER_ACTIVE,\n  S_POINTER_INACTIVE,\n  symbol,\n} from './common.js';\nimport type { Option } from './select.js';\n\nexport interface GroupMultiSelectOptions<Value> extends CommonOptions {\n  message: string;\n  options: Record<string, Option<Value>[]>;\n  initialValues?: Value[];\n  required?: boolean;\n  cursorAt?: Value;\n  selectableGroups?: boolean;\n  groupSpacing?: number;\n}\nexport const groupMultiselect = <Value>(opts: GroupMultiSelectOptions<Value>) => {\n  const { selectableGroups = true, groupSpacing = 0 } = opts;\n  const hasGuide = opts.withGuide ?? false;\n  const nestedPrefix = '  ';\n  const withMarkerAndPrefix = (\n    marker: string,\n    prefix: string,\n    prefixWidth: number,\n    label: string,\n    format: (text: string) => string,\n    firstLineSuffix = '',\n    spacingPrefix = '',\n  ) => {\n    const lines = label.split('\\n');\n    const continuationPrefix = `${S_POINTER_INACTIVE} ${' '.repeat(prefixWidth)}`;\n    if (lines.length === 1) {\n      return `${spacingPrefix}${marker} ${prefix}${format(lines[0])}${firstLineSuffix}`;\n    }\n    const [firstLine, ...rest] = lines;\n    return [\n      `${spacingPrefix}${marker} ${prefix}${format(firstLine)}${firstLineSuffix}`,\n      ...rest.map((line) => `${continuationPrefix}${format(line)}`),\n    ].join('\\n');\n  };\n\n  const opt = (\n    option: Option<Value> & { group: string | boolean },\n    state:\n      | 'inactive'\n      | 'active'\n      | 'selected'\n      | 'active-selected'\n      | 'group-active'\n      | 'group-active-selected'\n      | 'submitted'\n      | 'cancelled',\n    options: (Option<Value> & { group: string | boolean })[] = [],\n  ) => {\n    const label = option.label ?? String(option.value);\n    const hint = option.hint ? ` ${color.gray(`(${option.hint})`)}` : '';\n    const isItem = typeof option.group === 'string';\n    const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true });\n    const isLast = isItem && next && next.group === true;\n    const branchPrefixRaw = isItem\n      ? selectableGroups\n        ? `${isLast ? S_BAR_END : S_BAR} `\n        : '  '\n      : '';\n    let spacingPrefix = '';\n    if (groupSpacing > 0 && !isItem) {\n      const spacingPrefixText = hasGuide ? `\\n${color.blue(S_BAR)}` : '\\n';\n      const spacingSuffix = hasGuide ? ' ' : '';\n      spacingPrefix = `${spacingPrefixText.repeat(groupSpacing - 1)}${spacingPrefixText}${spacingSuffix}`;\n    }\n\n    if (state === 'cancelled') {\n      return color.strikethrough(color.dim(label));\n    }\n    if (state === 'submitted') {\n      return color.dim(label);\n    }\n\n    const marker =\n      state === 'active' || state === 'active-selected'\n        ? color.blue(S_POINTER_ACTIVE)\n        : color.dim(S_POINTER_INACTIVE);\n    const branchPrefix = color.dim(branchPrefixRaw);\n    const hasCheckbox = isItem || selectableGroups;\n    const checkboxRaw = hasCheckbox\n      ? state === 'active' || state === 'group-active'\n        ? S_CHECKBOX_ACTIVE\n        : state === 'selected' || state === 'active-selected' || state === 'group-active-selected'\n          ? S_CHECKBOX_SELECTED\n          : S_CHECKBOX_INACTIVE\n      : '';\n    const checkbox = hasCheckbox\n      ? checkboxRaw === S_CHECKBOX_SELECTED\n        ? color.blue(checkboxRaw)\n        : checkboxRaw === S_CHECKBOX_ACTIVE\n          ? color.blue(checkboxRaw)\n          : color.dim(checkboxRaw)\n      : '';\n    const format =\n      state === 'active' || state === 'active-selected'\n        ? (text: string) => color.blue(color.bold(text))\n        : color.dim;\n    const styledPrefix = `${branchPrefix}${hasCheckbox ? `${checkbox} ` : ''}`;\n    const prefixWidth = branchPrefixRaw.length + (hasCheckbox ? checkboxRaw.length + 1 : 0);\n\n    return withMarkerAndPrefix(\n      marker,\n      styledPrefix,\n      prefixWidth,\n      label,\n      format,\n      hint,\n      spacingPrefix,\n    );\n  };\n  const required = opts.required ?? true;\n\n  return new GroupMultiSelectPrompt({\n    options: opts.options,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    initialValues: opts.initialValues,\n    required,\n    cursorAt: opts.cursorAt,\n    selectableGroups,\n    validate(selected: Value[] | undefined) {\n      if (required && (selected === undefined || selected.length === 0)) {\n        return `Please select at least one option.\\n${color.reset(\n          color.dim(\n            `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(\n              color.bgWhite(color.inverse(' enter ')),\n            )} to submit`,\n          ),\n        )}`;\n      }\n    },\n    render() {\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${symbol(this.state)} ${opts.message}\\n`;\n      const value = this.value ?? [];\n\n      switch (this.state) {\n        case 'submit': {\n          const selectedOptions = this.options\n            .filter(({ value: optionValue }) => value.includes(optionValue))\n            .map((option) => opt(option, 'submitted'));\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const optionsText =\n            selectedOptions.length === 0 ? '' : selectedOptions.join(color.dim(', '));\n          return `${title}${submitPrefix}${optionsText}\\n\\n`;\n        }\n        case 'cancel': {\n          const label = this.options\n            .filter(({ value: optionValue }) => value.includes(optionValue))\n            .map((option) => opt(option, 'cancelled'))\n            .join(color.dim(', '));\n          if (!label.trim()) {\n            return hasGuide ? `${title}${color.gray(S_BAR)}\\n\\n` : `${title.trimEnd()}\\n\\n`;\n          }\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return hasGuide\n            ? `${title}${cancelPrefix}${label}\\n${color.gray(S_BAR)}\\n\\n`\n            : `${title}${cancelPrefix}${label}\\n\\n`;\n        }\n        case 'error': {\n          const prefix = hasGuide ? `${color.yellow(S_BAR)} ` : nestedPrefix;\n          const footer = hasGuide\n            ? this.error\n                .split('\\n')\n                .map((ln, i) =>\n                  i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : `  ${ln}`,\n                )\n                .join('\\n')\n            : `${nestedPrefix}${color.yellow(this.error)}`;\n          return `${title}${prefix}${this.options\n            .map((option, i, options) => {\n              const selected =\n                value.includes(option.value) ||\n                (option.group === true && this.isGroupSelected(String(option.value)));\n              const active = i === this.cursor;\n              const groupActive =\n                !active &&\n                typeof option.group === 'string' &&\n                this.options[this.cursor].value === option.group;\n              if (groupActive) {\n                return opt(option, selected ? 'group-active-selected' : 'group-active', options);\n              }\n              if (active && selected) {\n                return opt(option, 'active-selected', options);\n              }\n              if (selected) {\n                return opt(option, 'selected', options);\n              }\n              return opt(option, active ? 'active' : 'inactive', options);\n            })\n            .join(`\\n${prefix}`)}\\n${footer}\\n`;\n        }\n        default: {\n          const optionsText = this.options\n            .map((option, i, options) => {\n              const selected =\n                value.includes(option.value) ||\n                (option.group === true && this.isGroupSelected(String(option.value)));\n              const active = i === this.cursor;\n              const groupActive =\n                !active &&\n                typeof option.group === 'string' &&\n                this.options[this.cursor].value === option.group;\n              let optionText = '';\n              if (groupActive) {\n                optionText = opt(\n                  option,\n                  selected ? 'group-active-selected' : 'group-active',\n                  options,\n                );\n              } else if (active && selected) {\n                optionText = opt(option, 'active-selected', options);\n              } else if (selected) {\n                optionText = opt(option, 'selected', options);\n              } else {\n                optionText = opt(option, active ? 'active' : 'inactive', options);\n              }\n              const prefix = i !== 0 && !optionText.startsWith('\\n') ? '  ' : '';\n              return `${prefix}${optionText}`;\n            })\n            .join(hasGuide ? `\\n${color.blue(S_BAR)}` : '\\n');\n          const optionsPrefix = optionsText.startsWith('\\n') ? '' : nestedPrefix;\n          const defaultPrefix = hasGuide ? color.blue(S_BAR) : '';\n          const defaultSuffix = hasGuide ? color.blue(S_BAR_END) : '';\n          return `${title}${defaultPrefix}${optionsPrefix}${optionsText}\\n${defaultSuffix}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<Value[] | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/group.ts",
    "content": "import { isCancel } from '@clack/core';\n\ntype Prettify<T> = {\n  [P in keyof T]: T[P];\n} & {};\n\nexport type PromptGroupAwaitedReturn<T> = {\n  [P in keyof T]: Exclude<Awaited<T[P]>, symbol>;\n};\n\nexport interface PromptGroupOptions<T> {\n  /**\n   * Control how the group can be canceled\n   * if one of the prompts is canceled.\n   */\n  onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;\n}\n\nexport type PromptGroup<T> = {\n  [P in keyof T]: (opts: {\n    results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;\n  }) => undefined | Promise<T[P] | undefined>;\n};\n\n/**\n * Define a group of prompts to be displayed\n * and return a results of objects within the group\n */\nexport const group = async <T>(\n  prompts: PromptGroup<T>,\n  opts?: PromptGroupOptions<T>,\n): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {\n  const results = {} as any;\n  const promptNames = Object.keys(prompts);\n\n  for (const name of promptNames) {\n    const prompt = prompts[name as keyof T];\n    const result = await prompt({ results })?.catch((e) => {\n      throw e;\n    });\n\n    // Pass the results to the onCancel function\n    // so the user can decide what to do with the results\n    // TODO: Switch to callback within core to avoid isCancel Fn\n    if (typeof opts?.onCancel === 'function' && isCancel(result)) {\n      results[name] = 'canceled';\n      opts.onCancel({ results });\n      continue;\n    }\n\n    results[name] = result;\n  }\n\n  return results;\n};\n"
  },
  {
    "path": "packages/prompts/src/index.ts",
    "content": "export { type ClackSettings, isCancel, settings, updateSettings } from '@clack/core';\n\nexport * from './autocomplete.js';\nexport * from './box.js';\nexport * from './common.js';\nexport * from './confirm.js';\nexport * from './group.js';\nexport * from './group-multi-select.js';\nexport * from './limit-options.js';\nexport * from './log.js';\nexport * from './messages.js';\nexport * from './multi-select.js';\nexport * from './note.js';\nexport * from './password.js';\nexport * from './path.js';\nexport * from './progress-bar.js';\nexport * from './select.js';\nexport * from './select-key.js';\nexport * from './spinner.js';\nexport * from './stream.js';\nexport * from './task.js';\nexport * from './task-log.js';\nexport * from './text.js';\n"
  },
  {
    "path": "packages/prompts/src/limit-options.ts",
    "content": "import type { Writable } from 'node:stream';\n\nimport { getColumns, getRows } from '@clack/core';\nimport { wrapAnsi } from 'fast-wrap-ansi';\nimport color from 'picocolors';\n\nimport type { CommonOptions } from './common.js';\n\nexport interface LimitOptionsParams<TOption> extends CommonOptions {\n  options: TOption[];\n  maxItems: number | undefined;\n  cursor: number;\n  style: (option: TOption, active: boolean) => string;\n  columnPadding?: number;\n  rowPadding?: number;\n}\n\nconst trimLines = (\n  groups: Array<string[]>,\n  initialLineCount: number,\n  startIndex: number,\n  endIndex: number,\n  maxLines: number,\n) => {\n  let lineCount = initialLineCount;\n  let removals = 0;\n  for (let i = startIndex; i < endIndex; i++) {\n    const group = groups[i];\n    lineCount = lineCount - group.length;\n    removals++;\n    if (lineCount <= maxLines) {\n      break;\n    }\n  }\n  return { lineCount, removals };\n};\n\nexport const limitOptions = <TOption>(params: LimitOptionsParams<TOption>): string[] => {\n  const { cursor, options, style } = params;\n  const output: Writable = params.output ?? process.stdout;\n  const columns = getColumns(output);\n  const columnPadding = params.columnPadding ?? 0;\n  const rowPadding = params.rowPadding ?? 4;\n  const maxWidth = columns - columnPadding;\n  const rows = getRows(output);\n  const overflowFormat = color.dim('...');\n\n  const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY;\n  const outputMaxItems = Math.max(rows - rowPadding, 0);\n  // We clamp to minimum 5 because anything less doesn't make sense UX wise\n  const maxItems = Math.max(Math.min(paramMaxItems, outputMaxItems), 5);\n  let slidingWindowLocation = 0;\n\n  if (cursor >= maxItems - 3) {\n    slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0);\n  }\n\n  let shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;\n  let shouldRenderBottomEllipsis =\n    maxItems < options.length && slidingWindowLocation + maxItems < options.length;\n\n  const slidingWindowLocationEnd = Math.min(slidingWindowLocation + maxItems, options.length);\n  const lineGroups: Array<string[]> = [];\n  let lineCount = 0;\n  if (shouldRenderTopEllipsis) {\n    lineCount++;\n  }\n  if (shouldRenderBottomEllipsis) {\n    lineCount++;\n  }\n\n  const slidingWindowLocationWithEllipsis =\n    slidingWindowLocation + (shouldRenderTopEllipsis ? 1 : 0);\n  const slidingWindowLocationEndWithEllipsis =\n    slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0);\n\n  for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) {\n    const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth, {\n      hard: true,\n      trim: false,\n    }).split('\\n');\n    lineGroups.push(wrappedLines);\n    lineCount += wrappedLines.length;\n  }\n\n  if (lineCount > outputMaxItems) {\n    let precedingRemovals = 0;\n    let followingRemovals = 0;\n    let newLineCount = lineCount;\n    const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis;\n    const trimLinesLocal = (startIndex: number, endIndex: number) =>\n      trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems);\n\n    if (shouldRenderTopEllipsis) {\n      ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(\n        0,\n        cursorGroupIndex,\n      ));\n      if (newLineCount > outputMaxItems) {\n        ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(\n          cursorGroupIndex + 1,\n          lineGroups.length,\n        ));\n      }\n    } else {\n      ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal(\n        cursorGroupIndex + 1,\n        lineGroups.length,\n      ));\n      if (newLineCount > outputMaxItems) {\n        ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal(\n          0,\n          cursorGroupIndex,\n        ));\n      }\n    }\n\n    if (precedingRemovals > 0) {\n      shouldRenderTopEllipsis = true;\n      lineGroups.splice(0, precedingRemovals);\n    }\n    if (followingRemovals > 0) {\n      shouldRenderBottomEllipsis = true;\n      lineGroups.splice(lineGroups.length - followingRemovals, followingRemovals);\n    }\n  }\n\n  const result: string[] = [];\n  if (shouldRenderTopEllipsis) {\n    result.push(overflowFormat);\n  }\n  for (const lineGroup of lineGroups) {\n    for (const line of lineGroup) {\n      result.push(line);\n    }\n  }\n  if (shouldRenderBottomEllipsis) {\n    result.push(overflowFormat);\n  }\n\n  return result;\n};\n"
  },
  {
    "path": "packages/prompts/src/log.ts",
    "content": "import color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_ERROR,\n  S_INFO,\n  S_STEP_SUBMIT,\n  S_SUCCESS,\n  S_WARN,\n  completeColor,\n} from './common.js';\n\nexport interface LogMessageOptions extends CommonOptions {\n  symbol?: string;\n  spacing?: number;\n  secondarySymbol?: string;\n}\n\nexport const log = {\n  message: (\n    message: string | string[] = [],\n    {\n      symbol = color.gray(S_BAR),\n      secondarySymbol = color.gray(S_BAR),\n      output = process.stdout,\n      spacing = 1,\n      withGuide,\n    }: LogMessageOptions = {},\n  ) => {\n    const parts: string[] = [];\n    const hasGuide = withGuide ?? false;\n    const spacingString = !hasGuide ? '' : secondarySymbol;\n    const prefix = !hasGuide ? '' : `${symbol}  `;\n    const secondaryPrefix = !hasGuide ? '' : `${secondarySymbol}  `;\n\n    for (let i = 0; i < spacing; i++) {\n      parts.push(spacingString);\n    }\n\n    const messageParts = Array.isArray(message) ? message : message.split('\\n');\n    if (messageParts.length > 0) {\n      const [firstLine, ...lines] = messageParts;\n      if (firstLine.length > 0) {\n        parts.push(`${prefix}${firstLine}`);\n      } else {\n        parts.push(hasGuide ? symbol : '');\n      }\n      for (const ln of lines) {\n        if (ln.length > 0) {\n          parts.push(`${secondaryPrefix}${ln}`);\n        } else {\n          parts.push(hasGuide ? secondarySymbol : '');\n        }\n      }\n    }\n    output.write(`${parts.join('\\n')}\\n`);\n  },\n  info: (message: string, opts?: LogMessageOptions) => {\n    log.message(message, { ...opts, symbol: color.blue(S_INFO) });\n  },\n  success: (message: string, opts?: LogMessageOptions) => {\n    log.message(message, { ...opts, symbol: completeColor(S_SUCCESS) });\n  },\n  step: (message: string, opts?: LogMessageOptions) => {\n    log.message(message, { ...opts, symbol: completeColor(S_STEP_SUBMIT) });\n  },\n  warn: (message: string, opts?: LogMessageOptions) => {\n    log.message(message, { ...opts, symbol: color.yellow(S_WARN) });\n  },\n  /** alias for `log.warn()`. */\n  warning: (message: string, opts?: LogMessageOptions) => {\n    log.warn(message, opts);\n  },\n  error: (message: string, opts?: LogMessageOptions) => {\n    log.message(message, { ...opts, symbol: color.red(S_ERROR) });\n  },\n};\n"
  },
  {
    "path": "packages/prompts/src/messages.ts",
    "content": "import type { Writable } from 'node:stream';\n\nimport color from 'picocolors';\n\nimport { type CommonOptions } from './common.js';\n\nexport const cancel = (message = '', opts?: CommonOptions) => {\n  const output: Writable = opts?.output ?? process.stdout;\n  output.write(`${color.red(message)}\\n\\n`);\n};\n\nexport const intro = (title = '', opts?: CommonOptions) => {\n  const output: Writable = opts?.output ?? process.stdout;\n  output.write(`${title}\\n\\n`);\n};\n\nexport const outro = (message = '', opts?: CommonOptions) => {\n  const output: Writable = opts?.output ?? process.stdout;\n  output.write(`${message}\\n\\n`);\n};\n"
  },
  {
    "path": "packages/prompts/src/multi-select.ts",
    "content": "import { MultiSelectPrompt, wrapTextWithPrefix } from '@clack/core';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_CHECKBOX_ACTIVE,\n  S_CHECKBOX_INACTIVE,\n  S_CHECKBOX_SELECTED,\n  S_POINTER_ACTIVE,\n  S_POINTER_INACTIVE,\n  symbol,\n  symbolBar,\n} from './common.js';\nimport { limitOptions } from './limit-options.js';\nimport type { Option } from './select.js';\n\nexport interface MultiSelectOptions<Value> extends CommonOptions {\n  message: string;\n  options: Option<Value>[];\n  initialValues?: Value[];\n  maxItems?: number;\n  required?: boolean;\n  cursorAt?: Value;\n}\nconst computeLabel = (label: string, format: (text: string) => string) => {\n  return label\n    .split('\\n')\n    .map((line) => format(line))\n    .join('\\n');\n};\n\nconst withMarkerAndCheckbox = (\n  marker: string,\n  checkbox: string,\n  checkboxWidth: number,\n  label: string,\n  format: (text: string) => string,\n  firstLineSuffix = '',\n) => {\n  const lines = label.split('\\n');\n  const continuationPrefix = `${S_POINTER_INACTIVE} ${' '.repeat(checkboxWidth)} `;\n  if (lines.length === 1) {\n    return `${marker} ${checkbox} ${format(lines[0])}${firstLineSuffix}`;\n  }\n  const [firstLine, ...rest] = lines;\n  return [\n    `${marker} ${checkbox} ${format(firstLine)}${firstLineSuffix}`,\n    ...rest.map((line) => `${continuationPrefix}${format(line)}`),\n  ].join('\\n');\n};\n\nexport const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {\n  const opt = (\n    option: Option<Value>,\n    state:\n      | 'inactive'\n      | 'active'\n      | 'selected'\n      | 'active-selected'\n      | 'submitted'\n      | 'cancelled'\n      | 'disabled',\n  ) => {\n    const label = option.label ?? String(option.value);\n    const hint = option.hint ? ` ${color.gray(`(${option.hint})`)}` : '';\n    if (state === 'disabled') {\n      return withMarkerAndCheckbox(\n        color.gray(S_POINTER_INACTIVE),\n        color.gray(S_CHECKBOX_INACTIVE),\n        S_CHECKBOX_INACTIVE.length,\n        label,\n        (str) => color.strikethrough(color.gray(str)),\n        option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : '',\n      );\n    }\n    if (state === 'active') {\n      return withMarkerAndCheckbox(\n        color.blue(S_POINTER_ACTIVE),\n        color.blue(S_CHECKBOX_ACTIVE),\n        S_CHECKBOX_ACTIVE.length,\n        label,\n        (text) => color.blue(color.bold(text)),\n        hint,\n      );\n    }\n    if (state === 'selected') {\n      return withMarkerAndCheckbox(\n        color.dim(S_POINTER_INACTIVE),\n        color.blue(S_CHECKBOX_SELECTED),\n        S_CHECKBOX_SELECTED.length,\n        label,\n        color.dim,\n        hint,\n      );\n    }\n    if (state === 'cancelled') {\n      return computeLabel(label, (text) => color.strikethrough(color.dim(text)));\n    }\n    if (state === 'active-selected') {\n      return withMarkerAndCheckbox(\n        color.blue(S_POINTER_ACTIVE),\n        color.blue(S_CHECKBOX_SELECTED),\n        S_CHECKBOX_SELECTED.length,\n        label,\n        (text) => color.blue(color.bold(text)),\n        hint,\n      );\n    }\n    if (state === 'submitted') {\n      return computeLabel(label, color.dim);\n    }\n    return withMarkerAndCheckbox(\n      color.dim(S_POINTER_INACTIVE),\n      color.dim(S_CHECKBOX_INACTIVE),\n      S_CHECKBOX_INACTIVE.length,\n      label,\n      color.dim,\n    );\n  };\n  const required = opts.required ?? true;\n\n  return new MultiSelectPrompt({\n    options: opts.options,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    initialValues: opts.initialValues,\n    required,\n    cursorAt: opts.cursorAt,\n    validate(selected: Value[] | undefined) {\n      if (required && (selected === undefined || selected.length === 0)) {\n        return `Please select at least one option.\\n${color.reset(\n          color.dim(\n            `Press ${color.gray(color.bgWhite(color.inverse(' space ')))} to select, ${color.gray(\n              color.bgWhite(color.inverse(' enter ')),\n            )} to submit`,\n          ),\n        )}`;\n      }\n    },\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      const formatMessageLines = (message: string) => {\n        const lines = message.split('\\n');\n        return lines\n          .map((line, index) => `${index === 0 ? `${symbol(this.state)} ` : nestedPrefix}${line}`)\n          .join('\\n');\n      };\n      const wrappedMessage = hasGuide\n        ? wrapTextWithPrefix(\n            opts.output,\n            opts.message,\n            `${symbolBar(this.state)} `,\n            `${symbol(this.state)} `,\n          )\n        : formatMessageLines(opts.message);\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${wrappedMessage}\\n`;\n      const value = this.value ?? [];\n\n      const styleOption = (option: Option<Value>, active: boolean) => {\n        if (option.disabled) {\n          return opt(option, 'disabled');\n        }\n        const selected = value.includes(option.value);\n        if (active && selected) {\n          return opt(option, 'active-selected');\n        }\n        if (selected) {\n          return opt(option, 'selected');\n        }\n        return opt(option, active ? 'active' : 'inactive');\n      };\n\n      switch (this.state) {\n        case 'submit': {\n          const submitText =\n            this.options\n              .filter(({ value: optionValue }) => value.includes(optionValue))\n              .map((option) => opt(option, 'submitted'))\n              .join(color.dim(', ')) || color.dim('none');\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const wrappedSubmitText = wrapTextWithPrefix(opts.output, submitText, submitPrefix);\n          return `${title}${wrappedSubmitText}\\n`;\n        }\n        case 'cancel': {\n          const label = this.options\n            .filter(({ value: optionValue }) => value.includes(optionValue))\n            .map((option) => opt(option, 'cancelled'))\n            .join(color.dim(', '));\n          if (label.trim() === '') {\n            return hasGuide ? `${title}${color.gray(S_BAR)}\\n` : `${title.trimEnd()}\\n`;\n          }\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const wrappedLabel = wrapTextWithPrefix(opts.output, label, cancelPrefix);\n          return hasGuide\n            ? `${title}${wrappedLabel}\\n${color.gray(S_BAR)}\\n`\n            : `${title}${wrappedLabel}\\n`;\n        }\n        case 'error': {\n          const prefix = hasGuide ? `${color.yellow(S_BAR)} ` : nestedPrefix;\n          const footer = hasGuide\n            ? this.error\n                .split('\\n')\n                .map((ln, i) =>\n                  i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : `  ${ln}`,\n                )\n                .join('\\n')\n            : `${nestedPrefix}${color.yellow(this.error)}`;\n          // Calculate rowPadding: title lines + footer lines (error message + trailing newline)\n          const titleLineCount = title.split('\\n').length;\n          const footerLineCount = footer.split('\\n').length + 1; // footer + trailing newline\n          return `${title}${prefix}${limitOptions({\n            output: opts.output,\n            options: this.options,\n            cursor: this.cursor,\n            maxItems: opts.maxItems,\n            columnPadding: prefix.length,\n            rowPadding: titleLineCount + footerLineCount,\n            style: styleOption,\n          }).join(`\\n${prefix}`)}\\n${footer}\\n`;\n        }\n        default: {\n          const prefix = hasGuide ? `${color.blue(S_BAR)} ` : nestedPrefix;\n          // Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)\n          const titleLineCount = title.split('\\n').length;\n          const footerLineCount = hasGuide ? 2 : 1; // S_BAR_END + trailing newline\n          return `${title}${prefix}${limitOptions({\n            output: opts.output,\n            options: this.options,\n            cursor: this.cursor,\n            maxItems: opts.maxItems,\n            columnPadding: prefix.length,\n            rowPadding: titleLineCount + footerLineCount,\n            style: styleOption,\n          }).join(`\\n${prefix}`)}\\n${hasGuide ? color.blue(S_BAR_END) : ''}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<Value[] | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/note.ts",
    "content": "import process from 'node:process';\nimport type { Writable } from 'node:stream';\n\nimport { getColumns } from '@clack/core';\nimport stringWidth from 'fast-string-width';\nimport { type Options as WrapAnsiOptions, wrapAnsi } from 'fast-wrap-ansi';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  completeColor,\n  S_BAR,\n  S_BAR_H,\n  S_CONNECT_LEFT,\n  S_CORNER_BOTTOM_LEFT,\n  S_CORNER_BOTTOM_RIGHT,\n  S_CORNER_TOP_RIGHT,\n  S_STEP_SUBMIT,\n} from './common.js';\n\ntype FormatFn = (line: string) => string;\nexport interface NoteOptions extends CommonOptions {\n  format?: FormatFn;\n}\n\nconst defaultNoteFormatter = (line: string): string => color.dim(line);\n\nconst wrapWithFormat = (message: string, width: number, format: FormatFn): string => {\n  const opts: WrapAnsiOptions = {\n    hard: true,\n    trim: false,\n  };\n  const wrapMsg = wrapAnsi(message, width, opts).split('\\n');\n  const maxWidthNormal = wrapMsg.reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);\n  const maxWidthFormat = wrapMsg.map(format).reduce((sum, ln) => Math.max(stringWidth(ln), sum), 0);\n  const wrapWidth = width - (maxWidthFormat - maxWidthNormal);\n  return wrapAnsi(message, wrapWidth, opts);\n};\n\nexport const note = (message = '', title = '', opts?: NoteOptions) => {\n  const output: Writable = opts?.output ?? process.stdout;\n  const hasGuide = opts?.withGuide ?? false;\n  const format = opts?.format ?? defaultNoteFormatter;\n  const wrapMsg = wrapWithFormat(message, getColumns(output) - 6, format);\n  const lines = ['', ...wrapMsg.split('\\n').map(format), ''];\n  const titleLen = stringWidth(title);\n  const len =\n    Math.max(\n      lines.reduce((sum, ln) => {\n        const width = stringWidth(ln);\n        return width > sum ? width : sum;\n      }, 0),\n      titleLen,\n    ) + 2;\n  const lineSymbol = hasGuide ? color.gray(S_BAR) : ' ';\n  const msg = lines\n    .map((ln) => `${lineSymbol}  ${ln}${' '.repeat(len - stringWidth(ln))}${lineSymbol}`)\n    .join('\\n');\n  const leadingBorder = hasGuide ? `${color.gray(S_BAR)}\\n` : '';\n  const bottomLeft = hasGuide ? S_CONNECT_LEFT : S_CORNER_BOTTOM_LEFT;\n  output.write(\n    `${leadingBorder}${completeColor(S_STEP_SUBMIT)}  ${color.reset(title)} ${color.gray(\n      S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT,\n    )}\\n${msg}\\n${color.gray(bottomLeft + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\\n`,\n  );\n};\n"
  },
  {
    "path": "packages/prompts/src/password.ts",
    "content": "import { PasswordPrompt } from '@clack/core';\nimport color from 'picocolors';\n\nimport { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from './common.js';\n\nexport interface PasswordOptions extends CommonOptions {\n  message: string;\n  mask?: string;\n  validate?: (value: string | undefined) => string | Error | undefined;\n  clearOnError?: boolean;\n}\nexport const password = (opts: PasswordOptions) => {\n  return new PasswordPrompt({\n    validate: opts.validate,\n    mask: opts.mask ?? S_PASSWORD_MASK,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${symbol(this.state)} ${opts.message}\\n`;\n      const userInput = this.userInputWithCursor;\n      const masked = this.masked;\n\n      switch (this.state) {\n        case 'error': {\n          const errorPrefix = hasGuide ? `${color.yellow(S_BAR)} ` : nestedPrefix;\n          const errorPrefixEnd = hasGuide ? `${color.yellow(S_BAR_END)} ` : '';\n          const maskedText = masked ?? '';\n          if (opts.clearOnError) {\n            this.clear();\n          }\n          return `${title.trim()}\\n${errorPrefix}${maskedText}\\n${errorPrefixEnd}${color.yellow(this.error)}\\n`;\n        }\n        case 'submit': {\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const maskedText = masked ? color.dim(masked) : '';\n          return `${title}${submitPrefix}${maskedText}\\n`;\n        }\n        case 'cancel': {\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const maskedText = masked ? color.strikethrough(color.dim(masked)) : '';\n          return `${title}${cancelPrefix}${maskedText}${\n            masked && hasGuide ? `\\n${color.gray(S_BAR)}` : ''\n          }\\n`;\n        }\n        default: {\n          const defaultPrefix = hasGuide ? `${color.blue(S_BAR)} ` : nestedPrefix;\n          const defaultPrefixEnd = hasGuide ? color.blue(S_BAR_END) : '';\n          return `${title}${defaultPrefix}${userInput}\\n${defaultPrefixEnd}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<string | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/path.ts",
    "content": "import { existsSync, lstatSync, readdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\nimport { autocomplete } from './autocomplete.js';\nimport type { CommonOptions } from './common.js';\n\nexport interface PathOptions extends CommonOptions {\n  root?: string;\n  directory?: boolean;\n  initialValue?: string;\n  message: string;\n  validate?: (value: string | undefined) => string | Error | undefined;\n}\n\nexport const path = (opts: PathOptions) => {\n  const validate = opts.validate;\n\n  return autocomplete({\n    ...opts,\n    initialUserInput: opts.initialValue ?? opts.root ?? process.cwd(),\n    maxItems: 5,\n    validate(value) {\n      if (Array.isArray(value)) {\n        // Shouldn't ever happen since we don't enable `multiple: true`\n        return undefined;\n      }\n      if (!value) {\n        return 'Please select a path';\n      }\n      if (validate) {\n        return validate(value);\n      }\n      return undefined;\n    },\n    options() {\n      const userInput = this.userInput;\n      if (userInput === '') {\n        return [];\n      }\n\n      try {\n        let searchPath: string;\n\n        if (!existsSync(userInput)) {\n          searchPath = dirname(userInput);\n        } else {\n          const stat = lstatSync(userInput);\n          if (stat.isDirectory()) {\n            searchPath = userInput;\n          } else {\n            searchPath = dirname(userInput);\n          }\n        }\n\n        const items = readdirSync(searchPath)\n          .map((item) => {\n            const path = join(searchPath, item);\n            const stats = lstatSync(path);\n            return {\n              name: item,\n              path,\n              isDirectory: stats.isDirectory(),\n            };\n          })\n          .filter(\n            ({ path, isDirectory }) =>\n              path.startsWith(userInput) && (opts.directory || !isDirectory),\n          );\n        return items.map((item) => ({\n          value: item.path,\n        }));\n      } catch {\n        return [];\n      }\n    },\n  });\n};\n"
  },
  {
    "path": "packages/prompts/src/progress-bar.ts",
    "content": "import type { State } from '@clack/core';\nimport color from 'picocolors';\n\nimport { completeColor, unicodeOr } from './common.js';\nimport { type SpinnerOptions, type SpinnerResult, spinner } from './spinner.js';\n\nconst S_PROGRESS_CHAR: Record<NonNullable<ProgressOptions['style']>, string> = {\n  light: unicodeOr('─', '-'),\n  heavy: unicodeOr('━', '='),\n  block: unicodeOr('█', '#'),\n};\n\nexport interface ProgressOptions extends SpinnerOptions {\n  style?: 'light' | 'heavy' | 'block';\n  max?: number;\n  size?: number;\n}\n\nexport interface ProgressResult extends SpinnerResult {\n  advance(step?: number, msg?: string): void;\n}\n\nexport function progress({\n  style = 'heavy',\n  max: userMax = 100,\n  size: userSize = 40,\n  ...spinnerOptions\n}: ProgressOptions = {}): ProgressResult {\n  const spin = spinner(spinnerOptions);\n  let value = 0;\n  let previousMessage = '';\n\n  const max = Math.max(1, userMax);\n  const size = Math.max(1, userSize);\n\n  const activeStyle = (state: State) => {\n    switch (state) {\n      case 'initial':\n      case 'active':\n        return color.magenta;\n      case 'error':\n      case 'cancel':\n        return color.red;\n      case 'submit':\n        return completeColor;\n      default:\n        return color.magenta;\n    }\n  };\n  const drawProgress = (state: State, msg: string) => {\n    const active = Math.floor((value / max) * size);\n    return `${activeStyle(state)(S_PROGRESS_CHAR[style].repeat(active))}${color.dim(S_PROGRESS_CHAR[style].repeat(size - active))} ${msg}`;\n  };\n\n  const start = (msg = '') => {\n    previousMessage = msg;\n    spin.start(drawProgress('initial', msg));\n  };\n  const advance = (step = 1, msg?: string): void => {\n    value = Math.min(max, step + value);\n    spin.message(drawProgress('active', msg ?? previousMessage));\n    previousMessage = msg ?? previousMessage;\n  };\n  return {\n    start,\n    pause: spin.pause.bind(spin),\n    resume: (msg?: string) => {\n      const nextMessage = msg ?? previousMessage;\n      previousMessage = nextMessage;\n      spin.resume(drawProgress('active', nextMessage));\n    },\n    stop: spin.stop.bind(spin),\n    cancel: spin.cancel.bind(spin),\n    error: spin.error.bind(spin),\n    clear: spin.clear.bind(spin),\n    advance,\n    message: (msg: string) => advance(0, msg),\n    get isCancelled() {\n      return spin.isCancelled;\n    },\n  };\n}\n"
  },
  {
    "path": "packages/prompts/src/select-key.ts",
    "content": "import { SelectKeyPrompt, wrapTextWithPrefix } from '@clack/core';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_POINTER_ACTIVE,\n  S_POINTER_INACTIVE,\n  symbol,\n} from './common.js';\nimport type { Option } from './select.js';\n\nexport interface SelectKeyOptions<Value extends string> extends CommonOptions {\n  message: string;\n  options: Option<Value>[];\n  initialValue?: Value;\n  caseSensitive?: boolean;\n}\n\nexport const selectKey = <Value extends string>(opts: SelectKeyOptions<Value>) => {\n  const withMarker = (marker: string, value: string) => {\n    const lines = value.split('\\n');\n    if (lines.length === 1) {\n      return `${marker} ${lines[0]}`;\n    }\n    const [firstLine, ...rest] = lines;\n    return [`${marker} ${firstLine}`, ...rest.map((line) => `${S_POINTER_INACTIVE} ${line}`)].join(\n      '\\n',\n    );\n  };\n\n  const opt = (\n    option: Option<Value>,\n    state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive',\n  ) => {\n    const label = option.label ?? String(option.value);\n    if (state === 'selected') {\n      return color.dim(label);\n    }\n    if (state === 'cancelled') {\n      return color.strikethrough(color.dim(label));\n    }\n    if (state === 'active') {\n      return withMarker(\n        color.blue(S_POINTER_ACTIVE),\n        `${color.bgBlue(color.white(` ${option.value} `))} ${color.bold(label)}${\n          option.hint ? ` ${color.dim(`(${option.hint})`)}` : ''\n        }`,\n      );\n    }\n    return withMarker(\n      color.dim(S_POINTER_INACTIVE),\n      `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${color.dim(label)}${\n        option.hint ? ` ${color.dim(`(${option.hint})`)}` : ''\n      }`,\n    );\n  };\n\n  return new SelectKeyPrompt({\n    options: opts.options,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    initialValue: opts.initialValue,\n    caseSensitive: opts.caseSensitive,\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${symbol(this.state)} ${opts.message}\\n`;\n\n      switch (this.state) {\n        case 'submit': {\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const selectedOption =\n            this.options.find((opt) => opt.value === this.value) ?? opts.options[0];\n          const wrapped = wrapTextWithPrefix(\n            opts.output,\n            opt(selectedOption, 'selected'),\n            submitPrefix,\n          );\n          return `${title}${wrapped}\\n`;\n        }\n        case 'cancel': {\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const wrapped = wrapTextWithPrefix(\n            opts.output,\n            opt(this.options[0], 'cancelled'),\n            cancelPrefix,\n          );\n          return `${title}${wrapped}${hasGuide ? `\\n${color.gray(S_BAR)}` : ''}\\n`;\n        }\n        default: {\n          const defaultPrefix = hasGuide ? `${color.blue(S_BAR)} ` : nestedPrefix;\n          const defaultPrefixEnd = hasGuide ? color.blue(S_BAR_END) : '';\n          const wrapped = this.options\n            .map((option, i) =>\n              wrapTextWithPrefix(\n                opts.output,\n                opt(option, i === this.cursor ? 'active' : 'inactive'),\n                defaultPrefix,\n              ),\n            )\n            .join('\\n');\n          return `${title}${wrapped}\\n${defaultPrefixEnd}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<Value | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/select.ts",
    "content": "import { SelectPrompt, wrapTextWithPrefix } from '@clack/core';\nimport color from 'picocolors';\n\nimport {\n  type CommonOptions,\n  S_BAR,\n  S_BAR_END,\n  S_POINTER_ACTIVE,\n  S_POINTER_INACTIVE,\n  symbol,\n  symbolBar,\n} from './common.js';\nimport { limitOptions } from './limit-options.js';\n\ntype Primitive = Readonly<string | boolean | number>;\n\nexport type Option<Value> = Value extends Primitive\n  ? {\n      /**\n       * Internal data for this option.\n       */\n      value: Value;\n      /**\n       * The optional, user-facing text for this option.\n       *\n       * By default, the `value` is converted to a string.\n       */\n      label?: string;\n      /**\n       * An optional hint to display to the user when\n       * this option might be selected.\n       *\n       * By default, no `hint` is displayed.\n       */\n      hint?: string;\n      /**\n       * Whether this option is disabled.\n       * Disabled options are visible but cannot be selected.\n       *\n       * By default, options are not disabled.\n       */\n      disabled?: boolean;\n    }\n  : {\n      /**\n       * Internal data for this option.\n       */\n      value: Value;\n      /**\n       * Required. The user-facing text for this option.\n       */\n      label: string;\n      /**\n       * An optional hint to display to the user when\n       * this option might be selected.\n       *\n       * By default, no `hint` is displayed.\n       */\n      hint?: string;\n      /**\n       * Whether this option is disabled.\n       * Disabled options are visible but cannot be selected.\n       *\n       * By default, options are not disabled.\n       */\n      disabled?: boolean;\n    };\n\nexport interface SelectOptions<Value> extends CommonOptions {\n  message: string;\n  options: Option<Value>[];\n  initialValue?: Value;\n  maxItems?: number;\n}\n\nconst computeLabel = (label: string, format: (text: string) => string) => {\n  if (!label.includes('\\n')) {\n    return format(label);\n  }\n  return label\n    .split('\\n')\n    .map((line) => format(line))\n    .join('\\n');\n};\n\nconst withMarker = (\n  marker: string,\n  label: string,\n  format: (text: string) => string,\n  firstLineSuffix = '',\n) => {\n  const lines = label.split('\\n');\n  if (lines.length === 1) {\n    return `${marker} ${format(lines[0])}${firstLineSuffix}`;\n  }\n  const [firstLine, ...rest] = lines;\n  return [\n    `${marker} ${format(firstLine)}${firstLineSuffix}`,\n    ...rest.map((line) => `${S_POINTER_INACTIVE} ${format(line)}`),\n  ].join('\\n');\n};\n\nexport const select = <Value>(opts: SelectOptions<Value>) => {\n  const opt = (\n    option: Option<Value>,\n    state: 'inactive' | 'active' | 'selected' | 'cancelled' | 'disabled',\n  ) => {\n    const label = option.label ?? String(option.value);\n    const hint = option.hint ? `: ${color.gray(option.hint)}` : '';\n    switch (state) {\n      case 'disabled':\n        return withMarker(\n          color.gray(S_POINTER_INACTIVE),\n          label,\n          (text) => color.strikethrough(color.gray(text)),\n          option.hint ? `: ${color.gray(option.hint ?? 'disabled')}` : '',\n        );\n      case 'selected':\n        return computeLabel(label, color.dim);\n      case 'active':\n        return withMarker(\n          color.blue(S_POINTER_ACTIVE),\n          label,\n          (text) => color.blue(color.bold(text)),\n          hint,\n        );\n      case 'cancelled':\n        return computeLabel(label, (str) => color.strikethrough(color.dim(str)));\n      default:\n        return withMarker(color.dim(S_POINTER_INACTIVE), label, (text) => text, hint);\n    }\n  };\n\n  return new SelectPrompt({\n    options: opts.options,\n    signal: opts.signal,\n    input: opts.input,\n    output: opts.output,\n    initialValue: opts.initialValue,\n    render() {\n      const hasGuide = opts.withGuide ?? false;\n      const nestedPrefix = '  ';\n      const formatMessageLines = (message: string) => {\n        const lines = message.split('\\n');\n        return lines\n          .map((line, index) => `${index === 0 ? `${symbol(this.state)} ` : nestedPrefix}${line}`)\n          .join('\\n');\n      };\n      const hasMessage = opts.message.trim().length > 0;\n      const messageLines = !hasMessage\n        ? ''\n        : hasGuide\n          ? wrapTextWithPrefix(\n              opts.output,\n              opts.message,\n              `${symbolBar(this.state)} `,\n              `${symbol(this.state)} `,\n            )\n          : formatMessageLines(opts.message);\n      const title = hasMessage\n        ? `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${messageLines}\\n`\n        : '';\n\n      switch (this.state) {\n        case 'submit': {\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const wrappedLines = wrapTextWithPrefix(\n            opts.output,\n            opt(this.options[this.cursor], 'selected'),\n            submitPrefix,\n          );\n          return `${title}${wrappedLines}\\n`;\n        }\n        case 'cancel': {\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          const wrappedLines = wrapTextWithPrefix(\n            opts.output,\n            opt(this.options[this.cursor], 'cancelled'),\n            cancelPrefix,\n          );\n          return `${title}${wrappedLines}${hasGuide ? `\\n${color.gray(S_BAR)}` : ''}\\n`;\n        }\n        default: {\n          const prefix = hasGuide ? `${color.blue(S_BAR)} ` : nestedPrefix;\n          const prefixEnd = hasGuide ? color.blue(S_BAR_END) : '';\n          // Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)\n          const titleLineCount = title ? title.split('\\n').length : 0;\n          const footerLineCount = hasGuide ? 2 : 1; // S_BAR_END + trailing newline (or just trailing newline)\n          return `${title}${prefix}${limitOptions({\n            output: opts.output,\n            cursor: this.cursor,\n            options: this.options,\n            maxItems: opts.maxItems,\n            columnPadding: prefix.length,\n            rowPadding: titleLineCount + footerLineCount,\n            style: (item, active) =>\n              opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'),\n          }).join(`\\n${prefix}`)}\\n${prefixEnd}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<Value | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/src/spinner.ts",
    "content": "import { block, getColumns, settings } from '@clack/core';\nimport { wrapAnsi } from 'fast-wrap-ansi';\nimport color from 'picocolors';\nimport { cursor, erase } from 'sisteransi';\n\nimport {\n  type CommonOptions,\n  completeColor,\n  isCI as isCIFn,\n  S_BAR,\n  S_STEP_CANCEL,\n  S_STEP_ERROR,\n  S_STEP_SUBMIT,\n  unicode,\n} from './common.js';\n\nexport interface SpinnerOptions extends CommonOptions {\n  indicator?: 'dots' | 'timer';\n  onCancel?: () => void;\n  cancelMessage?: string;\n  errorMessage?: string;\n  frames?: string[];\n  delay?: number;\n  styleFrame?: (frame: string) => string;\n}\n\nexport interface SpinnerResult {\n  start(msg?: string): void;\n  pause(): void;\n  resume(msg?: string): void;\n  stop(msg?: string): void;\n  cancel(msg?: string): void;\n  error(msg?: string): void;\n  message(msg?: string): void;\n  clear(): void;\n  readonly isCancelled: boolean;\n}\n\nconst defaultStyleFn: SpinnerOptions['styleFrame'] = color.magenta;\n\nconst removeTrailingDots = (msg: string): string => {\n  return msg.replace(/\\.+$/, '');\n};\n\nconst formatTimer = (durationMs: number): string => {\n  const duration = durationMs / 1000;\n  const min = Math.floor(duration / 60);\n  const secs = Math.floor(duration % 60);\n  return color.gray(min > 0 ? `(${min}m ${secs}s)` : `(${secs}s)`);\n};\n\nexport const spinner = ({\n  indicator = 'dots',\n  onCancel,\n  output = process.stdout,\n  cancelMessage,\n  errorMessage,\n  frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0'],\n  delay = unicode ? 80 : 120,\n  signal,\n  ...opts\n}: SpinnerOptions = {}): SpinnerResult => {\n  const isCI = isCIFn();\n\n  let unblock: () => void;\n  let loop: NodeJS.Timeout;\n  let isSpinnerActive = false;\n  let isCancelled = false;\n  let _message = '';\n  let _prevMessage: string | undefined;\n  let _origin: number = performance.now();\n  let _elapsedMs = 0;\n  const columns = getColumns(output);\n  const styleFn = opts?.styleFrame ?? defaultStyleFn;\n\n  const getElapsedMs = () => {\n    if (!isSpinnerActive) {\n      return _elapsedMs;\n    }\n    return _elapsedMs + (performance.now() - _origin);\n  };\n\n  const handleExit = (code: number) => {\n    const msg =\n      code > 1\n        ? (errorMessage ?? settings.messages.error)\n        : (cancelMessage ?? settings.messages.cancel);\n    isCancelled = code === 1;\n    if (isSpinnerActive) {\n      _stop(msg, code);\n      if (isCancelled && typeof onCancel === 'function') {\n        onCancel();\n      }\n    }\n  };\n\n  const errorEventHandler = () => handleExit(2);\n  const signalEventHandler = () => handleExit(1);\n\n  const registerHooks = () => {\n    // Reference: https://nodejs.org/api/process.html#event-uncaughtexception\n    process.on('uncaughtExceptionMonitor', errorEventHandler);\n    // Reference: https://nodejs.org/api/process.html#event-unhandledrejection\n    process.on('unhandledRejection', errorEventHandler);\n    // Reference Signal Events: https://nodejs.org/api/process.html#signal-events\n    process.on('SIGINT', signalEventHandler);\n    process.on('SIGTERM', signalEventHandler);\n    process.on('exit', handleExit);\n\n    if (signal) {\n      signal.addEventListener('abort', signalEventHandler);\n    }\n  };\n\n  const clearHooks = () => {\n    process.removeListener('uncaughtExceptionMonitor', errorEventHandler);\n    process.removeListener('unhandledRejection', errorEventHandler);\n    process.removeListener('SIGINT', signalEventHandler);\n    process.removeListener('SIGTERM', signalEventHandler);\n    process.removeListener('exit', handleExit);\n\n    if (signal) {\n      signal.removeEventListener('abort', signalEventHandler);\n    }\n  };\n\n  const clearPrevMessage = () => {\n    if (_prevMessage === undefined) {\n      return;\n    }\n    if (isCI) {\n      output.write('\\n');\n    }\n    const wrapped = wrapAnsi(_prevMessage, columns, {\n      hard: true,\n      trim: false,\n    });\n    const prevLines = wrapped.split('\\n');\n    if (prevLines.length > 1) {\n      output.write(cursor.up(prevLines.length - 1));\n    }\n    output.write(cursor.to(0));\n    output.write(erase.down());\n  };\n\n  const hasGuide = opts.withGuide ?? false;\n\n  const startLoop = (): void => {\n    isSpinnerActive = true;\n    unblock = block({ output });\n    _origin = performance.now();\n    _prevMessage = undefined;\n    if (hasGuide) {\n      output.write(`${color.gray(S_BAR)}\\n`);\n    }\n    let frameIndex = 0;\n    let indicatorTimer = 0;\n    registerHooks();\n    loop = setInterval(() => {\n      if (isCI && _message === _prevMessage) {\n        return;\n      }\n      clearPrevMessage();\n      _prevMessage = _message;\n      const frame = styleFn(frames[frameIndex]);\n      let outputMessage: string;\n\n      if (isCI) {\n        outputMessage = `${frame}  ${_message}...`;\n      } else if (indicator === 'timer') {\n        outputMessage = `${frame}  ${_message} ${formatTimer(getElapsedMs())}`;\n      } else {\n        const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3);\n        outputMessage = `${frame}  ${_message}${loadingDots}`;\n      }\n\n      const wrapped = wrapAnsi(outputMessage, columns, {\n        hard: true,\n        trim: false,\n      });\n      output.write(wrapped);\n\n      frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0;\n      // indicator increase by 1 every 8 frames\n      indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0;\n    }, delay);\n  };\n\n  const start = (msg = ''): void => {\n    _elapsedMs = 0;\n    _message = removeTrailingDots(msg);\n    startLoop();\n  };\n\n  const _stop = (msg = '', code = 0, silent: boolean = false, preserveElapsed = false): void => {\n    if (!isSpinnerActive) {\n      return;\n    }\n    isSpinnerActive = false;\n    clearInterval(loop);\n    clearPrevMessage();\n    const elapsedMs = getElapsedMs();\n    const step =\n      code === 0\n        ? completeColor(S_STEP_SUBMIT)\n        : code === 1\n          ? color.red(S_STEP_CANCEL)\n          : color.red(S_STEP_ERROR);\n    _message = msg ?? _message;\n    if (!silent) {\n      if (indicator === 'timer') {\n        output.write(`${step} ${_message} ${formatTimer(elapsedMs)}\\n\\n`);\n      } else {\n        output.write(`${step} ${_message}\\n\\n`);\n      }\n    }\n    if (!preserveElapsed) {\n      _elapsedMs = 0;\n    }\n    _prevMessage = undefined;\n    clearHooks();\n    unblock();\n  };\n\n  const pause = (): void => {\n    if (!isSpinnerActive) {\n      return;\n    }\n    _elapsedMs = getElapsedMs();\n    _stop(_message, 0, true, true);\n  };\n  const resume = (msg = _message): void => {\n    if (isSpinnerActive) {\n      return;\n    }\n    _message = removeTrailingDots(msg);\n    startLoop();\n  };\n  const stop = (msg = ''): void => _stop(msg, 0);\n  const cancel = (msg = ''): void => _stop(msg, 1);\n  const error = (msg = ''): void => _stop(msg, 2);\n  const clear = (): void => _stop('', 0, true);\n\n  const message = (msg = ''): void => {\n    _message = removeTrailingDots(msg ?? _message);\n  };\n\n  return {\n    start,\n    pause,\n    resume,\n    stop,\n    message,\n    cancel,\n    error,\n    clear,\n    get isCancelled() {\n      return isCancelled;\n    },\n  };\n};\n"
  },
  {
    "path": "packages/prompts/src/stream.ts",
    "content": "import { stripVTControlCharacters as strip } from 'node:util';\n\nimport color from 'picocolors';\n\nimport { S_ERROR, S_INFO, S_STEP_SUBMIT, S_SUCCESS, S_WARN, completeColor } from './common.js';\nimport type { LogMessageOptions } from './log.js';\n\nconst prefix = '   ';\n\n// TODO (43081j): this currently doesn't support custom `output` writable\n// because we rely on `columns` existing (i.e. `process.stdout.columns).\n//\n// If we want to support `output` being passed in, we will need to use\n// a condition like `if (output instance Writable)` to check if it has columns\nexport const stream = {\n  message: async (\n    iterable: Iterable<string> | AsyncIterable<string>,\n    { symbol = '' }: LogMessageOptions = {},\n  ) => {\n    process.stdout.write(symbol ? `${symbol}  ` : '');\n    const initialWidth = symbol ? 3 : 0;\n    let lineWidth = initialWidth;\n    for await (let chunk of iterable) {\n      chunk = chunk.replace(/\\n/g, `\\n${prefix}`);\n      if (chunk.includes('\\n')) {\n        lineWidth = initialWidth + strip(chunk.slice(chunk.lastIndexOf('\\n'))).length;\n      }\n      const chunkLen = strip(chunk).length;\n      if (lineWidth + chunkLen < process.stdout.columns) {\n        lineWidth += chunkLen;\n        process.stdout.write(chunk);\n      } else {\n        process.stdout.write(`\\n${prefix}${chunk.trimStart()}`);\n        lineWidth = initialWidth + strip(chunk.trimStart()).length;\n      }\n    }\n    process.stdout.write('\\n');\n  },\n  info: (iterable: Iterable<string> | AsyncIterable<string>) => {\n    return stream.message(iterable, { symbol: color.blue(S_INFO) });\n  },\n  success: (iterable: Iterable<string> | AsyncIterable<string>) => {\n    return stream.message(iterable, { symbol: completeColor(S_SUCCESS) });\n  },\n  step: (iterable: Iterable<string> | AsyncIterable<string>) => {\n    return stream.message(iterable, { symbol: completeColor(S_STEP_SUBMIT) });\n  },\n  warn: (iterable: Iterable<string> | AsyncIterable<string>) => {\n    return stream.message(iterable, { symbol: color.yellow(S_WARN) });\n  },\n  /** alias for `log.warn()`. */\n  warning: (iterable: Iterable<string> | AsyncIterable<string>) => {\n    return stream.warn(iterable);\n  },\n  error: (iterable: Iterable<string> | AsyncIterable<string>) => {\n    return stream.message(iterable, { symbol: color.red(S_ERROR) });\n  },\n};\n"
  },
  {
    "path": "packages/prompts/src/task-log.ts",
    "content": "import type { Writable } from 'node:stream';\n\nimport { getColumns } from '@clack/core';\nimport color from 'picocolors';\nimport { erase } from 'sisteransi';\n\nimport {\n  type CommonOptions,\n  completeColor,\n  isCI as isCIFn,\n  isTTY as isTTYFn,\n  S_BAR,\n  S_STEP_SUBMIT,\n} from './common.js';\nimport { log } from './log.js';\n\nexport interface TaskLogOptions extends CommonOptions {\n  title: string;\n  limit?: number;\n  spacing?: number;\n  retainLog?: boolean;\n}\n\nexport interface TaskLogMessageOptions {\n  raw?: boolean;\n}\n\nexport interface TaskLogCompletionOptions {\n  showLog?: boolean;\n}\n\ninterface BufferEntry {\n  header?: string;\n  value: string;\n  full: string;\n  result?: {\n    status: 'success' | 'error';\n    message: string;\n  };\n}\n\nconst stripDestructiveANSI = (input: string): string => {\n  // oxlint-disable-next-line no-control-regex\n  return input.replace(/\\x1b\\[(?:\\d+;)*\\d*[ABCDEFGHfJKSTsu]|\\x1b\\[(s|u)/g, '');\n};\n\n/**\n * Renders a log which clears on success and remains on failure\n */\nexport const taskLog = (opts: TaskLogOptions) => {\n  const output: Writable = opts.output ?? process.stdout;\n  const columns = getColumns(output);\n  const secondarySymbol = color.gray(S_BAR);\n  const spacing = opts.spacing ?? 1;\n  const barSize = 3;\n  const retainLog = opts.retainLog === true;\n  const isTTY = !isCIFn() && isTTYFn(output);\n\n  output.write(`${secondarySymbol}\\n`);\n  output.write(`${completeColor(S_STEP_SUBMIT)}  ${opts.title}\\n`);\n  for (let i = 0; i < spacing; i++) {\n    output.write(`${secondarySymbol}\\n`);\n  }\n\n  const buffers: BufferEntry[] = [\n    {\n      value: '',\n      full: '',\n    },\n  ];\n  let lastMessageWasRaw = false;\n\n  const clear = (clearTitle: boolean): void => {\n    if (buffers.length === 0) {\n      return;\n    }\n\n    let lines = 0;\n\n    if (clearTitle) {\n      lines += spacing + 2;\n    }\n\n    for (const buffer of buffers) {\n      const { value, result } = buffer;\n      let text = result?.message ?? value;\n\n      if (text.length === 0) {\n        continue;\n      }\n\n      if (result === undefined && buffer.header !== undefined && buffer.header !== '') {\n        text += `\\n${buffer.header}`;\n      }\n\n      const bufferHeight = text.split('\\n').reduce((count, line) => {\n        if (line === '') {\n          return count + 1;\n        }\n        return count + Math.ceil((line.length + barSize) / columns);\n      }, 0);\n\n      lines += bufferHeight;\n    }\n\n    if (lines > 0) {\n      lines += 1;\n      output.write(erase.lines(lines));\n    }\n  };\n  const printBuffer = (buffer: BufferEntry, messageSpacing?: number, full?: boolean): void => {\n    const messages = full ? `${buffer.full}\\n${buffer.value}` : buffer.value;\n    if (buffer.header !== undefined && buffer.header !== '') {\n      log.message(buffer.header.split('\\n').map(color.bold), {\n        output,\n        secondarySymbol,\n        symbol: secondarySymbol,\n        spacing: 0,\n      });\n    }\n    log.message(messages.split('\\n').map(color.dim), {\n      output,\n      secondarySymbol,\n      symbol: secondarySymbol,\n      spacing: messageSpacing ?? spacing,\n    });\n  };\n  const renderBuffer = (): void => {\n    for (const buffer of buffers) {\n      const { header, value, full } = buffer;\n      if ((header === undefined || header.length === 0) && value.length === 0) {\n        continue;\n      }\n      printBuffer(buffer, undefined, retainLog === true && full.length > 0);\n    }\n  };\n  const message = (buffer: BufferEntry, msg: string, mopts?: TaskLogMessageOptions) => {\n    clear(false);\n    if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') {\n      buffer.value += '\\n';\n    }\n    buffer.value += stripDestructiveANSI(msg);\n    lastMessageWasRaw = mopts?.raw === true;\n    if (opts.limit !== undefined) {\n      const lines = buffer.value.split('\\n');\n      const linesToRemove = lines.length - opts.limit;\n      if (linesToRemove > 0) {\n        const removedLines = lines.splice(0, linesToRemove);\n        if (retainLog) {\n          buffer.full += (buffer.full === '' ? '' : '\\n') + removedLines.join('\\n');\n        }\n      }\n      buffer.value = lines.join('\\n');\n    }\n    if (isTTY) {\n      printBuffers();\n    }\n  };\n  const printBuffers = (): void => {\n    for (const buffer of buffers) {\n      if (buffer.result) {\n        if (buffer.result.status === 'error') {\n          log.error(buffer.result.message, { output, secondarySymbol, spacing: 0 });\n        } else {\n          log.success(buffer.result.message, { output, secondarySymbol, spacing: 0 });\n        }\n      } else if (buffer.value !== '') {\n        printBuffer(buffer, 0);\n      }\n    }\n  };\n  const completeBuffer = (buffer: BufferEntry, result: BufferEntry['result']): void => {\n    clear(false);\n\n    buffer.result = result;\n\n    if (isTTY) {\n      printBuffers();\n    }\n  };\n\n  return {\n    message(msg: string, mopts?: TaskLogMessageOptions) {\n      message(buffers[0], msg, mopts);\n    },\n    group(name: string) {\n      const buffer: BufferEntry = {\n        header: name,\n        value: '',\n        full: '',\n      };\n      buffers.push(buffer);\n      return {\n        message(msg: string, mopts?: TaskLogMessageOptions) {\n          message(buffer, msg, mopts);\n        },\n        error(message: string) {\n          completeBuffer(buffer, {\n            status: 'error',\n            message,\n          });\n        },\n        success(message: string) {\n          completeBuffer(buffer, {\n            status: 'success',\n            message,\n          });\n        },\n      };\n    },\n    error(message: string, opts?: TaskLogCompletionOptions): void {\n      clear(true);\n      log.error(message, { output, secondarySymbol, spacing: 1 });\n      if (opts?.showLog !== false) {\n        renderBuffer();\n      }\n      // clear buffer since error is an end state\n      buffers.splice(1, buffers.length - 1);\n      buffers[0].value = '';\n      buffers[0].full = '';\n    },\n    success(message: string, opts?: TaskLogCompletionOptions): void {\n      clear(true);\n      log.success(message, { output, secondarySymbol, spacing: 1 });\n      if (opts?.showLog === true) {\n        renderBuffer();\n      }\n      // clear buffer since success is an end state\n      buffers.splice(1, buffers.length - 1);\n      buffers[0].value = '';\n      buffers[0].full = '';\n    },\n  };\n};\n"
  },
  {
    "path": "packages/prompts/src/task.ts",
    "content": "import type { CommonOptions } from './common.js';\nimport { spinner } from './spinner.js';\n\nexport type Task = {\n  /**\n   * Task title\n   */\n  title: string;\n  /**\n   * Task function\n   */\n  task: (message: (string: string) => void) => string | Promise<string> | void | Promise<void>;\n\n  /**\n   * If enabled === false the task will be skipped\n   */\n  enabled?: boolean;\n};\n\n/**\n * Define a group of tasks to be executed\n */\nexport const tasks = async (tasks: Task[], opts?: CommonOptions) => {\n  for (const task of tasks) {\n    if (task.enabled === false) {\n      continue;\n    }\n\n    const s = spinner(opts);\n    s.start(task.title);\n    const result = await task.task(s.message.bind(s));\n    s.stop(result || task.title);\n  }\n};\n"
  },
  {
    "path": "packages/prompts/src/text.ts",
    "content": "import { TextPrompt } from '@clack/core';\nimport color from 'picocolors';\n\nimport { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';\n\nexport interface TextOptions extends CommonOptions {\n  message: string;\n  placeholder?: string;\n  defaultValue?: string;\n  initialValue?: string;\n  validate?: (value: string | undefined) => string | Error | undefined;\n}\n\nexport const text = (opts: TextOptions) => {\n  return new TextPrompt({\n    validate: opts.validate,\n    placeholder: opts.placeholder,\n    defaultValue: opts.defaultValue,\n    initialValue: opts.initialValue,\n    output: opts.output,\n    signal: opts.signal,\n    input: opts.input,\n    render() {\n      const hasGuide = opts?.withGuide ?? false;\n      const nestedPrefix = '  ';\n      const title = `${hasGuide ? `${color.gray(S_BAR)}\\n` : ''}${symbol(this.state)} ${opts.message}\\n`;\n      const placeholder = opts.placeholder\n        ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1))\n        : color.inverse(color.hidden('_'));\n      const userInput = !this.userInput ? placeholder : this.userInputWithCursor;\n      const value = this.value ?? '';\n\n      switch (this.state) {\n        case 'error': {\n          const errorText = this.error ? ` ${color.yellow(this.error)}` : '';\n          const errorPrefix = hasGuide ? `${color.yellow(S_BAR)} ` : nestedPrefix;\n          const errorPrefixEnd = hasGuide ? color.yellow(S_BAR_END) : '';\n          return `${title.trim()}\\n${errorPrefix}${userInput}\\n${errorPrefixEnd}${errorText}\\n`;\n        }\n        case 'submit': {\n          const valueText = value ? color.dim(value) : '';\n          const submitPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return `${title}${submitPrefix}${valueText}\\n`;\n        }\n        case 'cancel': {\n          const valueText = value ? color.strikethrough(color.dim(value)) : '';\n          const cancelPrefix = hasGuide ? `${color.gray(S_BAR)} ` : nestedPrefix;\n          return `${title}${cancelPrefix}${valueText}${value.trim() ? `\\n${cancelPrefix}` : ''}\\n`;\n        }\n        default: {\n          const defaultPrefix = hasGuide ? `${color.blue(S_BAR)} ` : nestedPrefix;\n          const defaultPrefixEnd = hasGuide ? color.blue(S_BAR_END) : '';\n          return `${title}${defaultPrefix}${userInput}\\n${defaultPrefixEnd}\\n`;\n        }\n      }\n    },\n  }).prompt() as Promise<string | symbol>;\n};\n"
  },
  {
    "path": "packages/prompts/tsdown.config.ts",
    "content": "import { defineConfig } from 'tsdown';\n\nexport default defineConfig({\n  inlineOnly: false,\n});\n"
  },
  {
    "path": "packages/test/.gitignore",
    "content": "*.d.ts\n*.d.cts\n/LICENSE\nLICENSE.md\n*.cjs\n*.mjs\nbrowser/\ndist/\n"
  },
  {
    "path": "packages/test/BUNDLING.md",
    "content": "# Test Package Bundling Architecture\n\nThis document explains how `@voidzero-dev/vite-plus-test` bundles vitest and its dependencies.\n\n## Overview\n\nThe test package uses a **hybrid bundling strategy**:\n\n1. **COPY** all `@vitest/*` packages (preserves browser/Node.js separation)\n2. **BUNDLE** only leaf dependencies like `chai`, `pathe` (reduces install size)\n3. **Separate entries** (`index.js` vs `index-node.js`) prevent Node.js code from loading in browsers\n\nThis approach avoids the critical issue of Rolldown creating shared chunks that mix Node.js-only code (like `__vite__injectQuery`) with browser code, which causes runtime crashes.\n\n## Dependencies Classification\n\n### Copied Packages (`dist/@vitest/`)\n\nThese 11 `@vitest/*` packages are **copied** (not bundled) to preserve their original file structure:\n\n| Package                       | Purpose                                              |\n| ----------------------------- | ---------------------------------------------------- |\n| `@vitest/runner`              | Test runner core                                     |\n| `@vitest/utils`               | Utilities (source-map, error, display, timers, etc.) |\n| `@vitest/spy`                 | Spy/mock implementation                              |\n| `@vitest/expect`              | Assertion library                                    |\n| `@vitest/snapshot`            | Snapshot testing                                     |\n| `@vitest/mocker`              | Module mocking (node, browser, automock)             |\n| `@vitest/pretty-format`       | Output formatting                                    |\n| `@vitest/browser`             | Browser testing support                              |\n| `@vitest/browser-playwright`  | Playwright integration                               |\n| `@vitest/browser-webdriverio` | WebdriverIO integration                              |\n| `@vitest/browser-preview`     | Preview (testing-library) integration                |\n\n**Why copy instead of bundle?** Bundling would create shared chunks that mix browser-safe and Node.js-only code. Copying preserves the original separation.\n\n### Bundled Leaf Dependencies (`dist/vendor/`)\n\nThese packages are bundled using Rolldown into `dist/vendor/*.mjs`:\n\n| Package               | Purpose                             |\n| --------------------- | ----------------------------------- |\n| `chai`                | Assertion library (core of expect)  |\n| `pathe`               | Path utilities                      |\n| `tinyrainbow`         | Terminal colors                     |\n| `magic-string`        | String manipulation for source maps |\n| `estree-walker`       | AST traversal                       |\n| `why-is-node-running` | Debug tool for hanging processes    |\n\nThese were moved from `dependencies` to `devDependencies` since they're bundled.\n\n### Runtime Dependencies (NOT Bundled)\n\nThese remain in `dependencies` and are installed with the package:\n\n| Package           | Reason Not Bundled                            |\n| ----------------- | --------------------------------------------- |\n| `sirv`            | Static file server - complex runtime behavior |\n| `ws`              | WebSocket server - native bindings            |\n| `pixelmatch`      | Image comparison - optional feature           |\n| `pngjs`           | PNG handling - optional feature               |\n| `es-module-lexer` | ESM parsing - small, used at runtime          |\n| `expect-type`     | Type testing - small                          |\n| `obug`            | Debugging - small                             |\n| `picomatch`       | Glob matching - small                         |\n| `std-env`         | Environment detection - small                 |\n| `tinybench`       | Benchmarking - optional feature               |\n| `tinyexec`        | Command execution - small                     |\n| `tinyglobby`      | File globbing - small                         |\n\n### External Blocklist (Must NOT Bundle)\n\nThese packages are explicitly kept external in `EXTERNAL_BLOCKLIST` during the Rolldown build:\n\n| Package                 | Reason                                    |\n| ----------------------- | ----------------------------------------- |\n| `playwright`            | Native bindings, user must install        |\n| `webdriverio`           | Native bindings, user must install        |\n| `debug`                 | Environment detection breaks when bundled |\n| `happy-dom`             | Optional peer dependency                  |\n| `jsdom`                 | Optional peer dependency                  |\n| `@edge-runtime/vm`      | Optional peer dependency                  |\n| `@standard-schema/spec` | Types-only import from @vitest/expect     |\n| `msw`, `msw/*`          | Optional peer dependency for mocking      |\n\n### Browser Plugin Exclude List\n\nAdditionally, these packages are added to the **browser plugin's exclude list** (in `patchVitestBrowserPackage`), which prevents Vite's optimizer from bundling them during browser tests:\n\n| Package               | Reason                                           |\n| --------------------- | ------------------------------------------------ |\n| `lightningcss`        | Native bindings                                  |\n| `@tailwindcss/oxide`  | Native bindings                                  |\n| `tailwindcss`         | Pulls in @tailwindcss/oxide                      |\n| `@vitest/browser`     | Needs vendor-aliases plugin resolution           |\n| `@vitest/ui`          | Optional peer dependency                         |\n| `@vitest/mocker/node` | Imports @voidzero-dev/vite-plus-core (Node-only) |\n\nThis is a different mechanism than `EXTERNAL_BLOCKLIST` - it controls runtime optimization, not build-time bundling.\n\n---\n\n## Migration Guide\n\nFor maintainers developing the vitest/vite migration feature, here are the transformations needed.\n\n### Import Rewrites\n\n| Original Import                      | Rewritten Import                                          |\n| ------------------------------------ | --------------------------------------------------------- |\n| `from \"@vitest/browser-playwright\"`  | `from \"@voidzero-dev/vite-plus-test/browser-playwright\"`  |\n| `from \"@vitest/browser-webdriverio\"` | `from \"@voidzero-dev/vite-plus-test/browser-webdriverio\"` |\n| `from \"@vitest/browser-preview\"`     | `from \"@voidzero-dev/vite-plus-test/browser-preview\"`     |\n| `from \"vite\"`                        | `from \"@voidzero-dev/vite-plus-core\"`                     |\n| `from \"vite/module-runner\"`          | `from \"@voidzero-dev/vite-plus-core/module-runner\"`       |\n\n**Note**: `@voidzero-dev/vite-plus-core` is the bundled version of upstream vite (Vite v8 beta). See [Core Package Bundling](../core/BUNDLING.md) for details on what it contains.\n\n**Note:** When using pnpm overrides, you have three options for browser provider imports:\n\n- `vitest/browser-playwright` (or `vitest/browser-webdriverio`, `vitest/browser-preview`) - works when `vitest` is overridden to our package (Recommended)\n- `@voidzero-dev/vite-plus-test/browser-playwright` - direct import from test package\n- `vite-plus/test/plugins/browser-playwright` - direct import from CLI package\n\nImporting from `@vitest/browser-*` packages directly requires additional overrides for those specific packages.\n\n### package.json Changes\n\n**Remove these devDependencies** (now bundled):\n\n```json\n{\n  \"devDependencies\": {\n    \"@vitest/browser\": \"...\", // Remove\n    \"@vitest/browser-playwright\": \"...\", // Remove (if using playwright)\n    \"@vitest/browser-webdriverio\": \"...\", // Remove (if using webdriverio)\n    \"@vitest/browser-preview\": \"...\", // Remove (if using testing-library)\n    \"@vitest/ui\": \"...\" // Remove (peer dep, not bundled but optional)\n  }\n}\n```\n\n**Add pnpm overrides**:\n\n```yaml\n# pnpm-workspace.yaml\noverrides:\n  vite: 'file:path/to/vite-plus-core.tgz'\n  vitest: 'file:path/to/vite-plus-test.tgz'\n  '@vitest/browser': 'file:path/to/vite-plus-test.tgz'\n  '@vitest/browser-playwright': 'file:path/to/vite-plus-test.tgz'\n  '@vitest/browser-webdriverio': 'file:path/to/vite-plus-test.tgz'\n  '@vitest/browser-preview': 'file:path/to/vite-plus-test.tgz'\n```\n\nOr using npm package names:\n\n```yaml\noverrides:\n  vite: 'npm:@voidzero-dev/vite-plus-core'\n  vitest: 'npm:@voidzero-dev/vite-plus-test'\n  '@vitest/browser': 'npm:@voidzero-dev/vite-plus-test'\n  '@vitest/browser-playwright': 'npm:@voidzero-dev/vite-plus-test'\n  '@vitest/browser-webdriverio': 'npm:@voidzero-dev/vite-plus-test'\n  '@vitest/browser-preview': 'npm:@voidzero-dev/vite-plus-test'\n```\n\n### Config File Updates\n\n```typescript\n// Before (playwright)\nimport { playwright } from '@vitest/browser-playwright';\n\n// After - Option 1 (Recommended): Via vitest subpath (works when vitest is overridden)\nimport { playwright } from 'vitest/browser-playwright';\n\n// After - Option 2: Direct import from test package\nimport { playwright } from '@voidzero-dev/vite-plus-test/browser-playwright';\n\n// After - Option 3: Direct import from CLI package\nimport { playwright } from 'vite-plus/test/plugins/browser-playwright';\n```\n\nSimilarly for WebdriverIO:\n\n```typescript\nimport { webdriverio } from 'vitest/browser-webdriverio';\n```\n\nAnd for Preview (testing-library):\n\n```typescript\nimport { preview } from 'vitest/browser-preview';\n```\n\n### Plugin Exports for pnpm Overrides\n\nThe package provides `./plugins/*` exports to enable pnpm overrides for all `@vitest/*` packages:\n\n```\n@vitest/runner              -> @voidzero-dev/vite-plus-test/plugins/runner\n@vitest/utils               -> @voidzero-dev/vite-plus-test/plugins/utils\n@vitest/utils/error         -> @voidzero-dev/vite-plus-test/plugins/utils-error\n@vitest/spy                 -> @voidzero-dev/vite-plus-test/plugins/spy\n@vitest/expect              -> @voidzero-dev/vite-plus-test/plugins/expect\n@vitest/snapshot            -> @voidzero-dev/vite-plus-test/plugins/snapshot\n@vitest/mocker              -> @voidzero-dev/vite-plus-test/plugins/mocker\n@vitest/pretty-format       -> @voidzero-dev/vite-plus-test/plugins/pretty-format\n@vitest/browser             -> @voidzero-dev/vite-plus-test/plugins/browser\n@vitest/browser-playwright  -> @voidzero-dev/vite-plus-test/plugins/browser-playwright\n@vitest/browser-webdriverio -> @voidzero-dev/vite-plus-test/plugins/browser-webdriverio\n@vitest/browser-preview     -> @voidzero-dev/vite-plus-test/plugins/browser-preview\n```\n\n---\n\n## Ensuring Bundle Stability\n\n### Build-time Validation\n\nThe build script includes `validateExternalDeps()` which:\n\n1. Scans all bundled JS files using `oxc-parser`\n2. Extracts all external import specifiers\n3. Verifies every external dependency is declared in `dependencies` or `peerDependencies`\n4. Reports undeclared externals that would fail at runtime\n\nIf this validation fails, the build will report which packages need to be added.\n\n### Testing Strategy\n\n| Test Type                                                       | What It Validates                                  |\n| --------------------------------------------------------------- | -------------------------------------------------- |\n| **Snap tests** (`packages/cli/snap-tests/vitest-browser-mode/`) | Browser mode works after bundling                  |\n| **Ecosystem CI** (`ecosystem-ci/`)                              | Real-world projects work with bundled vitest       |\n| **CI workflows**                                                | Multi-platform validation (Ubuntu, Windows, macOS) |\n\n### Vitest Upgrade Checklist\n\nWhen upgrading the vitest version:\n\n1. **Update version** in `packages/test/package.json`:\n\n   ```json\n   {\n     \"devDependencies\": {\n       \"vitest-dev\": \"^NEW_VERSION\",\n       \"@vitest/runner\": \"NEW_VERSION\",\n       \"@vitest/utils\": \"NEW_VERSION\"\n       // ... all @vitest/* packages\n     }\n   }\n   ```\n\n2. **Run build**:\n\n   ```bash\n   pnpm -C packages/test build\n   ```\n\n3. **Check for new externals**: If `validateExternalDeps()` reports new undeclared dependencies:\n   - Add to `dependencies` if it should be installed at runtime\n   - Add to `EXTERNAL_BLOCKLIST` if it should remain external (native bindings, optional)\n   - If it's a new leaf dep, it will be automatically bundled\n\n4. **Run tests**:\n\n   ```bash\n   pnpm test\n   ```\n\n5. **Run ecosystem CI**:\n   ```bash\n   pnpm -C ecosystem-ci test\n   ```\n\n### Common Upgrade Issues\n\n| Issue                   | Cause                          | Solution                                           |\n| ----------------------- | ------------------------------ | -------------------------------------------------- |\n| New undeclared external | New vitest dependency          | Add to `dependencies` or `EXTERNAL_BLOCKLIST`      |\n| Browser test crashes    | Node.js code leaked to browser | Check import rewriting in `rewriteVitestImports()` |\n| Missing export          | New @vitest/\\* subpath export  | Add to `VITEST_PACKAGE_TO_PATH`                    |\n| pnpm override fails     | New plugin export needed       | Add to `createPluginExports()`                     |\n\n---\n\n## Technical Reference\n\n### Build Flow\n\n```\n1. bundleVitest()              Copy vitest-dev dist/ -> dist/\n2. copyVitestPackages()        Copy @vitest/* -> dist/@vitest/\n3. convertTabsToSpaces()       Normalize formatting for patches\n4. collectLeafDependencies()   Parse imports with oxc-parser\n5. bundleLeafDeps()            Bundle chai, pathe, etc -> dist/vendor/\n6. rewriteVitestImports()      Rewrite @vitest/*, vitest/*, vite\n7. patchVitestPkgRootPaths()   Fix distRoot for relocated files\n8. patchVitestBrowserPackage() Inject vendor-aliases plugin\n9. patchBrowserProviderLocators() Fix browser-safe imports\n10. Post-processing:\n    - patchVendorPaths()\n    - createBrowserCompatShim()\n    - createModuleRunnerStub()   Browser-safe stub\n    - createNodeEntry()          index-node.js with browser-provider\n    - copyBrowserClientFiles()\n    - createBrowserEntryFiles()  browser/ entry files at package root\n    - createPluginExports()      dist/plugins/* for pnpm overrides\n    - mergePackageJson()\n    - validateExternalDeps()\n```\n\n### Output Structure\n\n```\nbrowser/                       # Entry files for ./browser export\n├── context.js                 # Runtime guard (throws if not in browser)\n└── context.d.ts               # Re-exports from dist/@vitest/browser/context.d.ts\ndist/\n├── @vitest/                    # Copied packages (browser/Node.js safe)\n│   ├── runner/\n│   ├── utils/\n│   ├── spy/\n│   ├── expect/\n│   ├── snapshot/\n│   ├── mocker/\n│   ├── pretty-format/\n│   ├── browser/\n│   └── browser-playwright/\n├── vendor/                     # Bundled leaf dependencies\n│   ├── chai.mjs\n│   ├── pathe.mjs\n│   ├── tinyrainbow.mjs\n│   ├── magic-string.mjs\n│   ├── estree-walker.mjs\n│   ├── why-is-node-running.mjs\n│   └── vitest_*.mjs            # Browser stubs\n├── plugins/                    # Shims for pnpm overrides\n│   ├── runner.mjs\n│   ├── utils.mjs\n│   └── ... (33+ files)\n├── chunks/                     # Vitest core chunks\n├── client/                     # Browser client files\n├── index.js                    # Browser-safe entry\n├── index-node.js               # Node.js entry (includes browser-provider)\n├── module-runner-stub.js       # Browser-safe module-runner\n└── browser-compat.js           # @vitest/browser compatibility shim\n```\n\n### Browser/Node.js Separation\n\nThe critical design decision is maintaining separation between browser and Node.js code:\n\n| Entry Point          | Used By               | Contains                          |\n| -------------------- | --------------------- | --------------------------------- |\n| `dist/index.js`      | Browser tests         | No Node.js-only code              |\n| `dist/index-node.js` | Node.js (config, CLI) | Includes browser-provider exports |\n\nThis is achieved through:\n\n1. Conditional exports in package.json (`\"node\": \"./dist/index-node.js\"`)\n2. Browser-safe stubs for `module-runner`\n3. Import rewriting to prevent Node.js code from being pulled into browser bundles\n4. `vendor-aliases` plugin injection to resolve imports at runtime:\n   - Handles `@vitest/*` imports → resolves to copied `dist/@vitest/` files\n   - Handles `vitest/*` subpaths → resolves to dist files (enables `vitest/browser-playwright` usage)\n   - Handles `vitest/browser-playwright`, `vitest/browser-webdriverio`, `vitest/browser-preview` → resolves to bundled browser providers\n   - Handles `@voidzero-dev/vite-plus-test/*` subpaths → maps to equivalent vitest paths\n   - Handles `vite-plus/test/*` subpaths → maps to equivalent vitest paths (CLI package)\n   - Intercepts `vitest/browser`, `@voidzero-dev/vite-plus-test/browser`, `vite-plus/test/browser` → returns virtual module ID for BrowserContext plugin\n\n### Key Constants\n\n```typescript\n// Packages copied to dist/@vitest/\nconst VITEST_PACKAGES_TO_COPY = [\n  '@vitest/runner',\n  '@vitest/utils',\n  '@vitest/spy',\n  '@vitest/expect',\n  '@vitest/snapshot',\n  '@vitest/mocker',\n  '@vitest/pretty-format',\n  '@vitest/browser',\n  '@vitest/browser-playwright',\n  '@vitest/browser-webdriverio',\n  '@vitest/browser-preview',\n];\n\n// Packages that must NOT be bundled (from build.ts lines 131-158)\nconst EXTERNAL_BLOCKLIST = new Set([\n  // Our own packages - resolved at runtime\n  '@voidzero-dev/vite-plus-core',\n  '@voidzero-dev/vite-plus-core/module-runner',\n  'vite',\n  'vitest',\n\n  // Peer dependencies - consumers must provide these\n  '@edge-runtime/vm',\n  '@opentelemetry/api',\n  '@standard-schema/spec', // Types-only import from @vitest/expect\n  'happy-dom',\n  'jsdom',\n\n  // Optional dependencies with bundling issues or native bindings\n  'debug', // environment detection broken when bundled\n  'playwright', // native bindings\n  'webdriverio', // native bindings\n\n  // Runtime deps (in package.json dependencies) - not bundled, resolved at install time\n  'sirv',\n  'ws',\n  'pixelmatch',\n  'pngjs',\n\n  // MSW (Mock Service Worker) - optional peer dep of @vitest/mocker\n  'msw',\n  'msw/browser',\n  'msw/core/http',\n]);\n```\n"
  },
  {
    "path": "packages/test/__tests__/build-artifacts.spec.ts",
    "content": "/**\n * Verify that the @voidzero-dev/vite-plus-test build output (dist/)\n * contains the expected files and that patches applied during the build\n * (in build.ts) produce correct artifacts.\n *\n * These tests run against the already-built dist/ directory, ensuring\n * that re-packaging patches produce correct artifacts.\n */\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport url from 'node:url';\n\nimport { describe, expect, it } from 'vitest';\n\nconst testPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');\nconst distDir = path.join(testPkgDir, 'dist');\n\nfunction findCliApiChunk(): string {\n  const chunksDir = path.join(distDir, 'chunks');\n  const files = fs.readdirSync(chunksDir);\n  const chunk = files.find((f) => f.startsWith('cli-api.') && f.endsWith('.js'));\n  if (!chunk) {\n    throw new Error('cli-api chunk not found in dist/chunks/');\n  }\n  return path.join(chunksDir, chunk);\n}\n\ndescribe('build artifacts', () => {\n  describe('@vitest/browser/context.js', () => {\n    const contextPath = path.join(distDir, '@vitest/browser/context.js');\n\n    it('should exist', () => {\n      expect(fs.existsSync(contextPath), `${contextPath} should exist`).toBe(true);\n    });\n\n    it('should export page, cdp, and utils', () => {\n      const content = fs.readFileSync(contextPath, 'utf-8');\n      expect(content).toMatch(/export\\s*\\{[^}]*page[^}]*\\}/);\n      expect(content).toMatch(/export\\s*\\{[^}]*cdp[^}]*\\}/);\n      expect(content).toMatch(/export\\s*\\{[^}]*utils[^}]*\\}/);\n    });\n  });\n\n  /**\n   * The vitest:vendor-aliases plugin must NOT resolve @vitest/browser/context\n   * to the static file. If it does, the BrowserContext plugin's virtual module\n   * (which provides the `server` export) is bypassed.\n   *\n   * See: https://github.com/voidzero-dev/vite-plus/issues/1086\n   */\n  describe('vitest:vendor-aliases plugin (regression test for #1086)', () => {\n    const browserIndexPath = path.join(distDir, '@vitest/browser/index.js');\n\n    it('should not map @vitest/browser/context in vendorMap', () => {\n      const content = fs.readFileSync(browserIndexPath, 'utf-8');\n      // The vendorMap inside vitest:vendor-aliases should NOT contain\n      // '@vitest/browser/context' — it must be left for BrowserContext\n      // plugin to resolve as a virtual module.\n      const vendorAliasesMatch = content.match(\n        /name:\\s*['\"]vitest:vendor-aliases['\"][\\s\\S]*?const vendorMap\\s*=\\s*\\{([\\s\\S]*?)\\}/,\n      );\n      expect(vendorAliasesMatch, 'vitest:vendor-aliases plugin should exist').toBeTruthy();\n      const vendorMapContent = vendorAliasesMatch![1];\n      expect(vendorMapContent).not.toContain(\"'@vitest/browser/context'\");\n    });\n  });\n\n  /**\n   * Third-party packages that call `expect.extend()` internally\n   * (e.g., @testing-library/jest-dom) break under npm override because\n   * the vitest module instance is split, causing matchers to be registered\n   * on a different `chai` instance than the test runner uses.\n   *\n   * The build patches vitest's ModuleRunnerTransform plugin to auto-add\n   * these packages to `server.deps.inline`, so they are processed through\n   * Vite's transform pipeline and share the same module instance.\n   *\n   * See: https://github.com/voidzero-dev/vite-plus/issues/897\n   */\n  describe('server.deps.inline auto-inline (regression test for #897)', () => {\n    it('should contain the expected auto-inline packages', () => {\n      const content = fs.readFileSync(findCliApiChunk(), 'utf-8');\n      expect(content).toContain('Auto-inline packages');\n      expect(content).toContain('\"@testing-library/jest-dom\"');\n      expect(content).toContain('\"@storybook/test\"');\n      expect(content).toContain('\"jest-extended\"');\n    });\n\n    it('should not override user inline config when set to true', () => {\n      const content = fs.readFileSync(findCliApiChunk(), 'utf-8');\n      expect(content).toContain('server.deps.inline !== true');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/test/build.ts",
    "content": "// Build Script for @voidzero-dev/vite-plus-test\n//\n// Bundles vitest and @vitest/* dependencies with browser/Node.js separation.\n//\n// ┌─────────────────────────────────────────────────────────────────────┐\n// │                          BUILD FLOW                                 │\n// ├─────────────────────────────────────────────────────────────────────┤\n// │  1. bundleVitest()           Copy vitest-dev → dist/                │\n// │  2. copyVitestPackages()     Copy @vitest/* → dist/@vitest/         │\n// │  3. collectLeafDependencies() Parse imports with oxc-parser         │\n// │  4. bundleLeafDeps()         Bundle chai, pathe, etc → dist/vendor/ │\n// │  5. rewriteVitestImports()   Rewrite @vitest/*, vitest/*, vite      │\n// │  6. patchVitestPkgRootPaths() Fix distRoot for relocated files      │\n// │  7. patchVitestBrowserPackage() Inject vendor-aliases plugin        │\n// │  8. patchBrowserProviderLocators() Fix browser-safe imports         │\n// │  9. Post-processing:                                                │\n// │     - patchVendorPaths()                                            │\n// │     - createBrowserCompatShim()                                     │\n// │     - createModuleRunnerStub()   Browser-safe stub                  │\n// │     - createNodeEntry()          index-node.js with browser-provider│\n// │     - copyBrowserClientFiles()                                      │\n// │     - createPluginExports()      dist/plugins/* for pnpm overrides  │\n// │     - mergePackageJson()                                            │\n// │     - validateExternalDeps()                                        │\n// └─────────────────────────────────────────────────────────────────────┘\n//\n// Output Structure:\n//   dist/@vitest/*     - Copied packages (browser/Node.js safe)\n//   dist/vendor/*      - Bundled leaf dependencies\n//   dist/plugins/*     - Shims for pnpm overrides\n//   dist/index.js      - Browser-safe entry\n//   dist/index-node.js - Node.js entry (includes browser-provider)\n//\n// Key Design:\n//   - COPY @vitest/* to preserve browser/Node.js separation\n//   - BUNDLE only leaf deps (chai, etc.) to reduce install size\n//   - Separate entries prevent __vite__injectQuery errors in browser\n\nimport { existsSync } from 'node:fs';\nimport {\n  copyFile,\n  glob as fsGlob,\n  mkdir,\n  readFile,\n  readdir,\n  rm,\n  stat,\n  writeFile,\n} from 'node:fs/promises';\nimport { builtinModules } from 'node:module';\nimport { basename, join, parse, resolve, dirname, relative } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport { parseSync } from 'oxc-parser';\nimport { format } from 'oxfmt';\nimport { build } from 'rolldown';\nimport { dts } from 'rolldown-plugin-dts';\n\nimport { generateLicenseFile } from '../../scripts/generate-license.ts';\nimport pkg from './package.json' with { type: 'json' };\n\nconst projectDir = dirname(fileURLToPath(import.meta.url));\nconst vitestSourceDir = resolve(projectDir, 'node_modules/vitest-dev');\nconst distDir = resolve(projectDir, 'dist');\nconst vendorDir = resolve(distDir, 'vendor');\n\nconst CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core';\n\n// @vitest/* packages to copy (not bundle) to preserve browser/Node.js separation\n// These are copied from node_modules to dist/@vitest/ to avoid shared chunks\n// that mix Node.js-only code with browser code\nconst VITEST_PACKAGES_TO_COPY = [\n  '@vitest/runner',\n  '@vitest/utils',\n  '@vitest/spy',\n  '@vitest/expect',\n  '@vitest/snapshot',\n  '@vitest/mocker',\n  '@vitest/pretty-format',\n  '@vitest/browser',\n  '@vitest/browser-playwright',\n  '@vitest/browser-webdriverio',\n  '@vitest/browser-preview',\n] as const;\n\n// Mapping from @vitest/* package specifiers to their paths within dist/@vitest/\n// Used for import rewriting and vendor-aliases plugin\nconst VITEST_PACKAGE_TO_PATH: Record<string, string> = {\n  // @vitest/runner\n  '@vitest/runner': '@vitest/runner/index.js',\n  '@vitest/runner/utils': '@vitest/runner/utils.js',\n  '@vitest/runner/types': '@vitest/runner/types.js',\n  // @vitest/utils\n  '@vitest/utils': '@vitest/utils/index.js',\n  '@vitest/utils/source-map': '@vitest/utils/source-map.js',\n  '@vitest/utils/source-map/node': '@vitest/utils/source-map/node.js',\n  '@vitest/utils/error': '@vitest/utils/error.js',\n  '@vitest/utils/helpers': '@vitest/utils/helpers.js',\n  '@vitest/utils/display': '@vitest/utils/display.js',\n  '@vitest/utils/timers': '@vitest/utils/timers.js',\n  '@vitest/utils/highlight': '@vitest/utils/highlight.js',\n  '@vitest/utils/offset': '@vitest/utils/offset.js',\n  '@vitest/utils/resolver': '@vitest/utils/resolver.js',\n  '@vitest/utils/serialize': '@vitest/utils/serialize.js',\n  '@vitest/utils/constants': '@vitest/utils/constants.js',\n  '@vitest/utils/diff': '@vitest/utils/diff.js',\n  // @vitest/spy\n  '@vitest/spy': '@vitest/spy/index.js',\n  // @vitest/expect\n  '@vitest/expect': '@vitest/expect/index.js',\n  // @vitest/snapshot\n  '@vitest/snapshot': '@vitest/snapshot/index.js',\n  '@vitest/snapshot/environment': '@vitest/snapshot/environment.js',\n  '@vitest/snapshot/manager': '@vitest/snapshot/manager.js',\n  // @vitest/mocker\n  '@vitest/mocker': '@vitest/mocker/index.js',\n  '@vitest/mocker/node': '@vitest/mocker/node.js',\n  '@vitest/mocker/browser': '@vitest/mocker/browser.js',\n  '@vitest/mocker/redirect': '@vitest/mocker/redirect.js',\n  '@vitest/mocker/transforms': '@vitest/mocker/transforms.js',\n  '@vitest/mocker/automock': '@vitest/mocker/automock.js',\n  '@vitest/mocker/register': '@vitest/mocker/register.js',\n  // @vitest/pretty-format\n  '@vitest/pretty-format': '@vitest/pretty-format/index.js',\n  // @vitest/browser\n  '@vitest/browser': '@vitest/browser/index.js',\n  '@vitest/browser/context': '@vitest/browser/context.js',\n  '@vitest/browser/client': '@vitest/browser/client.js',\n  '@vitest/browser/locators': '@vitest/browser/locators.js',\n  // @vitest/browser-playwright\n  '@vitest/browser-playwright': '@vitest/browser-playwright/index.js',\n  // @vitest/browser-webdriverio\n  '@vitest/browser-webdriverio': '@vitest/browser-webdriverio/index.js',\n  // @vitest/browser-preview\n  '@vitest/browser-preview': '@vitest/browser-preview/index.js',\n};\n\n// Packages that should NOT be bundled into dist/vendor/ (remain external at runtime)\n// There are two categories:\n// 1. Runtime deps (also in package.json dependencies) - installed with the package, not bundled\n// 2. Peer/optional deps (also in peerDependencies) - users must install themselves\nconst EXTERNAL_BLOCKLIST = new Set([\n  // Our own packages - resolved at runtime\n  CORE_PACKAGE_NAME,\n  `${CORE_PACKAGE_NAME}/module-runner`,\n  'vite',\n  'vitest',\n\n  // Peer dependencies - consumers must provide these\n  '@edge-runtime/vm',\n  '@opentelemetry/api',\n  '@standard-schema/spec', // Types-only import from @vitest/expect\n  'happy-dom',\n  'jsdom',\n\n  // Optional dependencies with bundling issues or native bindings\n  'debug', // environment detection broken when bundled\n  'playwright', // native bindings\n  'webdriverio', // native bindings\n\n  // Runtime deps (in package.json dependencies) - not bundled, resolved at install time\n  'sirv',\n  'ws',\n  'pixelmatch',\n  'pngjs',\n\n  // MSW (Mock Service Worker) - optional peer dep of @vitest/mocker\n  'msw',\n  'msw/browser',\n  'msw/core/http',\n]);\n\n// CJS packages that need their default export destructured to named exports\nconst CJS_REEXPORT_PACKAGES = new Set(['expect-type']);\n\n// Node built-in modules (including node: prefix variants)\nconst NODE_BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);\n\n// Step 1: Copy vitest-dev dist files (rewriting vite -> core package)\nawait bundleVitest();\n\n// Step 1.5: Rebrand vitest CLI output as \"vp test\" with vite-plus version\nawait brandVitest();\n\n// Step 2: Copy @vitest/* packages from node_modules to dist/@vitest/\n// This preserves the original file structure to maintain browser/Node.js separation\nawait copyVitestPackages();\n\n// Step 2.5: Convert tabs to spaces in all copied JS files for consistent formatting\nawait convertTabsToSpaces();\n\n// Step 3: Collect leaf dependencies from copied @vitest/* files\n// These are external packages like tinyrainbow, pathe, chai, etc.\nconst leafDeps = await collectLeafDependencies();\n\n// Step 4: Bundle only leaf dependencies into dist/vendor/\n// Unlike bundling @vitest/* directly, this avoids shared chunks that mix browser/Node.js code\nconst leafDepToVendorPath = await bundleLeafDeps(leafDeps);\n\n// Step 5: Rewrite imports in copied @vitest/* and vitest-dev files\n// - @vitest/* -> relative paths to dist/@vitest/\n// - leaf deps -> relative paths to dist/vendor/\n// - vite -> @voidzero-dev/vite-plus-core\nawait rewriteVitestImports(leafDepToVendorPath);\n\n// Step 6: Fix pkgRoot resolution in all @vitest/* packages\n// Files are now at dist/@vitest/*/index.js, so \"../..\" needs to become \"../../..\"\nawait patchVitestPkgRootPaths();\n\n// Step 7: Patch @vitest/browser package (vendor-aliases plugin, exclude list)\nawait patchVitestBrowserPackage();\n\n// Step 8: Patch browser provider locators.js files for browser-safe imports\nawait patchBrowserProviderLocators();\n\n// Step 9: Post-processing\nawait patchVendorPaths();\nawait patchVitestCoreResolver();\nawait createBrowserCompatShim();\nawait createModuleRunnerStub();\nawait createNodeEntry();\nawait copyBrowserClientFiles();\nawait createBrowserEntryFiles();\nawait patchModuleAugmentations();\nawait patchChaiTypeReference();\nawait patchMockerHoistedModule();\nawait patchServerDepsInline();\nconst pluginExports = await createPluginExports();\nawait mergePackageJson(pluginExports);\ngenerateLicenseFile({\n  title: 'Vite-Plus test license',\n  packageName: 'Vite-Plus',\n  outputPath: join(projectDir, 'LICENSE.md'),\n  coreLicensePath: join(projectDir, '..', '..', 'LICENSE'),\n  bundledPaths: [distDir],\n  resolveFrom: [projectDir, join(projectDir, '..', '..')],\n  extraPackages: [\n    { packageDir: vitestSourceDir },\n    ...VITEST_PACKAGES_TO_COPY.map((packageName) => ({\n      packageDir: resolve(projectDir, 'node_modules', packageName),\n    })),\n  ],\n});\nif (!existsSync(join(projectDir, 'LICENSE.md'))) {\n  throw new Error('LICENSE.md was not generated during build');\n}\nawait syncLicenseFromRoot();\nawait validateExternalDeps();\n\nasync function mergePackageJson(pluginExports: Array<{ exportPath: string; shimFile: string }>) {\n  const vitestPackageJsonPath = join(vitestSourceDir, 'package.json');\n  const destPackageJsonPath = resolve(projectDir, 'package.json');\n\n  const vitestPkg = JSON.parse(await readFile(vitestPackageJsonPath, 'utf-8'));\n  const destPkg = JSON.parse(await readFile(destPackageJsonPath, 'utf-8'));\n\n  // Fields to merge from vitest-dev package.json (excluding dependencies since we bundle them)\n  const fieldsToMerge = [\n    'imports',\n    'exports',\n    'main',\n    'module',\n    'types',\n    'engines',\n    'peerDependencies',\n    'peerDependenciesMeta',\n  ] as const;\n\n  for (const field of fieldsToMerge) {\n    if (vitestPkg[field] !== undefined) {\n      destPkg[field] = vitestPkg[field];\n    }\n  }\n\n  // Remove bundled @vitest/* packages from peerDependencies\n  // These browser provider packages are now bundled, so users don't need to install them\n  const bundledPeerDeps = [\n    '@vitest/browser-playwright',\n    '@vitest/browser-webdriverio',\n    '@vitest/browser-preview',\n  ];\n  if (destPkg.peerDependencies) {\n    for (const dep of bundledPeerDeps) {\n      delete destPkg.peerDependencies[dep];\n    }\n  }\n  if (destPkg.peerDependenciesMeta) {\n    for (const dep of bundledPeerDeps) {\n      delete destPkg.peerDependenciesMeta[dep];\n    }\n  }\n\n  destPkg.bundledVersions = {\n    ...destPkg.bundledVersions,\n    vitest: vitestPkg.version,\n  };\n\n  // Add @vitest/browser compatible export (for when this package overrides @vitest/browser)\n  // The main \".\" export is what's used when code imports from @vitest/browser\n  if (destPkg.exports) {\n    // Add conditional Node.js export to the main entry\n    // Node.js code (like @vitest/browser-playwright) uses index-node.js which includes\n    // browser-provider exports. Browser code uses index.js which is safe.\n    // This separation prevents Node.js-only code (like __vite__injectQuery) from being\n    // loaded in the browser, which would cause \"Identifier already declared\" errors.\n    //\n    // IMPORTANT: The 'browser' condition must come BEFORE 'node' because vitest passes\n    // custom --conditions (like 'browser') to worker processes when frameworks like Nuxt\n    // set edge/cloudflare presets. Without the 'browser' condition here, Node.js would\n    // match 'node' first, loading index-node.js which imports @vitest/browser/index.js,\n    // which imports 'ws'. With --conditions browser active, 'ws' resolves to its browser\n    // stub (ws/browser.js) that doesn't export WebSocketServer, causing a SyntaxError.\n    // See: https://github.com/voidzero-dev/vite-plus/issues/831\n    if (destPkg.exports['.'] && destPkg.exports['.'].import) {\n      destPkg.exports['.'].import = {\n        types: destPkg.exports['.'].import.types,\n        browser: destPkg.exports['.'].import.default,\n        node: './dist/index-node.js',\n        default: destPkg.exports['.'].import.default,\n      };\n    }\n\n    destPkg.exports['./browser-compat'] = {\n      default: './dist/browser-compat.js',\n    };\n\n    // Add @vitest/browser-compatible subpath exports\n    // These are needed when this package is used as a pnpm override for @vitest/browser\n    // Files are copied to dist/ (not dist/vendor/) to match path resolution in bundled code\n    destPkg.exports['./client'] = {\n      default: './dist/client.js',\n    };\n    // Point to @vitest/browser/context.js so that tests and init scripts share the same module\n    // This is critical: the init script (locators.js) calls page.extend() on this module,\n    // and tests must use the SAME module instance to see the extended methods\n    destPkg.exports['./context'] = {\n      types: './browser/context.d.ts',\n      default: './dist/@vitest/browser/context.js',\n    };\n    // Also export ./browser/context for users importing vite-plus/test/browser/context\n    destPkg.exports['./browser/context'] = {\n      types: './browser/context.d.ts',\n      default: './dist/@vitest/browser/context.js',\n    };\n    destPkg.exports['./locators'] = {\n      default: './dist/locators.js',\n    };\n    destPkg.exports['./matchers'] = {\n      default: './dist/dummy.js', // Placeholder\n    };\n    destPkg.exports['./utils'] = {\n      default: './dist/dummy.js', // Placeholder\n    };\n\n    // Add @vitest/browser-playwright compatible export\n    // Users can import { playwright } from 'vitest/browser-playwright'\n    destPkg.exports['./browser-playwright'] = {\n      types: './dist/@vitest/browser-playwright/index.d.ts',\n      default: './dist/@vitest/browser-playwright/index.js',\n    };\n\n    // Add @vitest/browser-webdriverio compatible export\n    // Users can import { webdriverio } from 'vitest/browser-webdriverio'\n    destPkg.exports['./browser-webdriverio'] = {\n      types: './dist/@vitest/browser-webdriverio/index.d.ts',\n      default: './dist/@vitest/browser-webdriverio/index.js',\n    };\n\n    // Add @vitest/browser-preview compatible export\n    // Users can import { preview } from 'vitest/browser-preview'\n    destPkg.exports['./browser-preview'] = {\n      types: './dist/@vitest/browser-preview/index.d.ts',\n      default: './dist/@vitest/browser-preview/index.js',\n    };\n\n    // Add browser/providers/* alias exports for compatibility\n    // Some vitest examples use the nested path format\n    destPkg.exports['./browser/providers/playwright'] = {\n      types: './dist/@vitest/browser-playwright/index.d.ts',\n      default: './dist/@vitest/browser-playwright/index.js',\n    };\n    destPkg.exports['./browser/providers/webdriverio'] = {\n      types: './dist/@vitest/browser-webdriverio/index.d.ts',\n      default: './dist/@vitest/browser-webdriverio/index.js',\n    };\n    destPkg.exports['./browser/providers/preview'] = {\n      types: './dist/@vitest/browser-preview/index.d.ts',\n      default: './dist/@vitest/browser-preview/index.js',\n    };\n\n    // Add plugin exports for all bundled @vitest/* packages\n    // This allows pnpm overrides to redirect: @vitest/runner -> vitest/plugins/runner\n    for (const { exportPath, shimFile } of pluginExports) {\n      destPkg.exports[exportPath] = {\n        default: shimFile,\n      };\n    }\n  }\n\n  // Merge vitest dependencies into devDependencies (since we bundle them)\n  // Skip packages that are already in dependencies (runtime deps)\n  if (vitestPkg.dependencies) {\n    destPkg.devDependencies = destPkg.devDependencies || {};\n    for (const [dep, version] of Object.entries(vitestPkg.dependencies)) {\n      // Skip vite - we use our own core package\n      if (dep === 'vite') {\n        continue;\n      }\n      // Skip packages already in dependencies (they're runtime deps, not dev-only)\n      if (destPkg.dependencies && destPkg.dependencies[dep]) {\n        continue;\n      }\n      // Don't override existing devDependencies\n      if (!destPkg.devDependencies[dep]) {\n        destPkg.devDependencies[dep] = version;\n      }\n    }\n  }\n\n  const { code, errors } = await format(\n    destPackageJsonPath,\n    JSON.stringify(destPkg, null, 2) + '\\n',\n    {\n      experimentalSortPackageJson: true,\n    },\n  );\n  if (errors.length > 0) {\n    for (const error of errors) {\n      console.error(error);\n    }\n    process.exit(1);\n  }\n  await writeFile(destPackageJsonPath, code);\n}\n\nasync function syncLicenseFromRoot() {\n  const rootLicensePath = join(projectDir, '..', '..', 'LICENSE');\n  const packageLicensePath = join(projectDir, 'LICENSE');\n  await copyFile(rootLicensePath, packageLicensePath);\n}\n\nasync function bundleVitest() {\n  const vitestDestDir = projectDir;\n\n  await mkdir(vitestDestDir, { recursive: true });\n\n  // Get all vitest files excluding node_modules and package.json\n  const vitestFiles = fsGlob(join(vitestSourceDir, '**/*'), {\n    exclude: [\n      join(vitestSourceDir, 'node_modules/**'),\n      join(vitestSourceDir, 'package.json'),\n      join(vitestSourceDir, 'README.md'),\n    ],\n  });\n\n  for await (const file of vitestFiles) {\n    const stats = await stat(file);\n    if (!stats.isFile()) {\n      continue;\n    }\n\n    const relativePath = file.replace(vitestSourceDir, '');\n    const destPath = join(vitestDestDir, relativePath);\n\n    await mkdir(parse(destPath).dir, { recursive: true });\n\n    // Rewrite vite imports in .js, .mjs, and .cjs files\n    if (\n      file.endsWith('.js') ||\n      file.endsWith('.mjs') ||\n      file.endsWith('.cjs') ||\n      file.endsWith('.d.ts') ||\n      file.endsWith('.d.cts')\n    ) {\n      let content = await readFile(file, 'utf-8');\n      content = content\n        .replaceAll(/from ['\"]vite['\"]/g, `from '${CORE_PACKAGE_NAME}'`)\n        .replaceAll(/import\\(['\"]vite['\"]\\)/g, `import('${CORE_PACKAGE_NAME}')`)\n        .replaceAll(/require\\(['\"]vite['\"]\\)/g, `require('${CORE_PACKAGE_NAME}')`)\n        .replaceAll(/require\\(\"vite\"\\)/g, `require(\"${CORE_PACKAGE_NAME}\")`)\n        .replaceAll(`import 'vite';`, `import '${CORE_PACKAGE_NAME}';`)\n        .replaceAll(`'vite/module-runner'`, `'${CORE_PACKAGE_NAME}/module-runner'`)\n        .replaceAll(`declare module \"vite\"`, `declare module \"${CORE_PACKAGE_NAME}\"`);\n      console.log(`Replaced vite imports in ${destPath}`);\n      await writeFile(destPath, content, 'utf-8');\n    } else {\n      await copyFile(file, destPath);\n    }\n  }\n}\n\n/**\n * Rebrand vitest CLI output as \"vp test\" with Vite+ banner styling.\n * Patches bundled chunks to replace vitest branding and align banner output.\n */\nasync function brandVitest() {\n  const chunksDir = resolve(projectDir, 'dist/chunks');\n  const cacFiles: string[] = [];\n  for await (const file of fsGlob(join(chunksDir, 'cac.*.js'))) {\n    cacFiles.push(file);\n  }\n  if (cacFiles.length === 0) {\n    throw new Error('brandVitest: no cac chunk found in dist/chunks/');\n  }\n  for (const cacFile of cacFiles) {\n    let content = await readFile(cacFile, 'utf-8');\n\n    function patchString(label: string, search: string | RegExp, replacement: string) {\n      const before = content;\n      content =\n        typeof search === 'string'\n          ? content.replace(search, replacement)\n          : content.replace(search, replacement);\n      if (content === before) {\n        throw new Error(\n          `brandVitest: failed to patch \"${label}\" — pattern not found in ${cacFile}`,\n        );\n      }\n    }\n\n    // 1. CLI name: cac(\"vitest\") → cac(\"vp test\")\n    patchString('cac name', 'cac(\"vitest\")', 'cac(\"vp test\")');\n\n    // 2. Version: var version = \"<semver>\" → use VITE_PLUS_VERSION env var with fallback\n    patchString(\n      'version',\n      /var version = \"(\\d+\\.\\d+\\.\\d+[^\"]*)\"/,\n      'var version = process.env.VITE_PLUS_VERSION || \"$1\"',\n    );\n\n    // 3. Banner regex: /^vitest\\/\\d+\\.\\d+\\.\\d+$/ → /^vp test\\/[\\d.]+$/\n    patchString('banner regex', '/^vitest\\\\/\\\\d+\\\\.\\\\d+\\\\.\\\\d+$/', '/^vp test\\\\/[\\\\d.]+$/');\n\n    // 4. Help text: $ vitest --help → $ vp test --help\n    patchString('help text', '$ vitest --help --expand-help', '$ vp test --help --expand-help');\n\n    await writeFile(cacFile, content, 'utf-8');\n    console.log(`Branded vitest → vp test in ${cacFile}`);\n  }\n\n  const cliApiFiles: string[] = [];\n  for await (const file of fsGlob(join(chunksDir, 'cli-api.*.js'))) {\n    cliApiFiles.push(file);\n  }\n  if (cliApiFiles.length === 0) {\n    throw new Error('brandVitest: no cli-api chunk found in dist/chunks/');\n  }\n\n  for (const cliApiFile of cliApiFiles) {\n    let content = await readFile(cliApiFile, 'utf-8');\n\n    function patchString(label: string, search: string | RegExp, replacement: string) {\n      const before = content;\n      content =\n        typeof search === 'string'\n          ? content.replace(search, replacement)\n          : content.replace(search, replacement);\n      if (content === before) {\n        throw new Error(\n          `brandVitest: failed to patch \"${label}\" — pattern not found in ${cliApiFile}`,\n        );\n      }\n    }\n\n    // Remove one extra leading newline before DEV/RUN banner.\n    patchString(\n      'banner leading newline',\n      /printBanner\\(\\) \\{\\n\\t\\tthis\\.log\\(\\);\\n/,\n      'printBanner() {\\n',\n    );\n\n    // Use a blue badge for both DEV and RUN.\n    patchString(\n      'banner color',\n      /const color = this\\.ctx\\.config\\.watch \\? \"blue\" : \"[a-z]+\";\\n\\t\\tconst mode = this\\.ctx\\.config\\.watch \\? \"DEV\" : \"RUN\";/,\n      'const mode = this.ctx.config.watch ? \"DEV\" : \"RUN\";\\n\\t\\tconst label = c.bold(c.inverse(c.blue(` ${mode} `)));',\n    );\n\n    // Remove the version from the banner line and render a high-contrast label.\n    patchString(\n      'banner version text',\n      /this\\.log\\(withLabel\\(color, mode, (?:\"\"|`v\\$\\{this\\.ctx\\.version\\} `)\\) \\+ c\\.gray\\(this\\.ctx\\.config\\.root\\)\\);/,\n      'this.log(`${label} ${c.gray(this.ctx.config.root)}`);',\n    );\n\n    await writeFile(cliApiFile, content, 'utf-8');\n    console.log(`Branded vitest banner in ${cliApiFile}`);\n  }\n}\n\n/**\n * Copy @vitest/* packages from node_modules to dist/@vitest/\n * This preserves the original file structure to maintain browser/Node.js separation.\n * Unlike bundling with Rolldown, copying avoids creating shared chunks that mix\n * Node.js-only code with browser code.\n */\nasync function copyVitestPackages() {\n  console.log('\\nCopying @vitest/* packages to dist/@vitest/...');\n\n  const vitestDir = resolve(distDir, '@vitest');\n  await rm(vitestDir, { recursive: true, force: true });\n  await mkdir(vitestDir, { recursive: true });\n\n  let totalCopied = 0;\n\n  for (const pkg of VITEST_PACKAGES_TO_COPY) {\n    const pkgName = pkg.replace('@vitest/', '');\n    const srcDir = resolve(projectDir, `node_modules/${pkg}/dist`);\n    const destPkgDir = resolve(vitestDir, pkgName);\n\n    try {\n      await stat(srcDir);\n    } catch {\n      console.log(`  Warning: ${pkg} not installed, skipping`);\n      continue;\n    }\n\n    console.log(`  Copying ${pkg}...`);\n    const copied = await copyDirRecursive(srcDir, destPkgDir);\n    totalCopied += copied;\n    console.log(`    -> ${copied} files`);\n\n    // Copy root .d.ts files from @vitest/browser package directory.\n    // These are type definitions that live at the package root (not in dist/),\n    // e.g. context.d.ts, matchers.d.ts, aria-role.d.ts, utils.d.ts.\n    // Dynamically scan instead of hardcoding to handle future upstream additions.\n    if (pkg === '@vitest/browser') {\n      const pkgRoot = resolve(projectDir, `node_modules/${pkg}`);\n      try {\n        const pkgEntries = await readdir(pkgRoot);\n        for (const entry of pkgEntries) {\n          if (entry.endsWith('.d.ts')) {\n            await copyFile(join(pkgRoot, entry), join(destPkgDir, entry));\n            console.log(`    + copied ${entry}`);\n            totalCopied++;\n          }\n        }\n      } catch {\n        // Package root not readable, skip\n      }\n    }\n  }\n\n  console.log(`\\nCopied ${totalCopied} files to dist/@vitest/`);\n}\n\n/**\n * Recursively copy a directory\n */\nasync function copyDirRecursive(srcDir: string, destDir: string): Promise<number> {\n  await mkdir(destDir, { recursive: true });\n  const entries = await readdir(srcDir, { withFileTypes: true });\n  let count = 0;\n\n  for (const entry of entries) {\n    const srcPath = join(srcDir, entry.name);\n    const destPath = join(destDir, entry.name);\n\n    if (entry.isDirectory()) {\n      count += await copyDirRecursive(srcPath, destPath);\n    } else if (entry.isFile()) {\n      await copyFile(srcPath, destPath);\n      count++;\n    }\n  }\n\n  return count;\n}\n\n/**\n * Collect leaf dependencies from copied @vitest/* files AND vitest core dist files.\n * These are external packages that should be bundled (tinyrainbow, pathe, chai, expect-type, etc.)\n * but NOT @vitest/*, vitest/*, vite/*, node built-ins, or blocklisted packages.\n */\nasync function collectLeafDependencies(): Promise<Set<string>> {\n  console.log('\\nCollecting leaf dependencies from dist/...');\n\n  const leafDeps = new Set<string>();\n  const vitestDir = resolve(distDir, '@vitest');\n\n  // Scan both @vitest/* packages AND vitest core dist files\n  const jsFiles = fsGlob([\n    join(vitestDir, '**/*.js'),\n    join(distDir, '*.js'),\n    join(distDir, 'chunks/*.js'),\n  ]);\n\n  for await (const file of jsFiles) {\n    const content = await readFile(file, 'utf-8');\n    const result = parseSync(file, content, { sourceType: 'module' });\n\n    // Collect ESM static imports\n    for (const imp of result.module.staticImports) {\n      const specifier = imp.moduleRequest.value;\n      if (isLeafDependency(specifier)) {\n        leafDeps.add(specifier);\n      }\n    }\n\n    // Collect ESM static exports (re-exports)\n    for (const exp of result.module.staticExports) {\n      for (const entry of exp.entries) {\n        if (entry.moduleRequest) {\n          const specifier = entry.moduleRequest.value;\n          if (isLeafDependency(specifier)) {\n            leafDeps.add(specifier);\n          }\n        }\n      }\n    }\n\n    // Collect dynamic imports (only string literals)\n    for (const dynImp of result.module.dynamicImports) {\n      const rawText = content.slice(dynImp.moduleRequest.start, dynImp.moduleRequest.end);\n      if (\n        (rawText.startsWith(\"'\") && rawText.endsWith(\"'\")) ||\n        (rawText.startsWith('\"') && rawText.endsWith('\"'))\n      ) {\n        const specifier = rawText.slice(1, -1);\n        if (isLeafDependency(specifier)) {\n          leafDeps.add(specifier);\n        }\n      }\n    }\n  }\n\n  console.log(`Found ${leafDeps.size} leaf dependencies:`);\n  for (const dep of leafDeps) {\n    console.log(`  - ${dep}`);\n  }\n\n  return leafDeps;\n}\n\n/**\n * Check if a specifier is a leaf dependency that should be bundled.\n * Leaf deps are external packages that are NOT:\n * - @vitest/* (we copy these)\n * - vitest or vitest/* (we copy vitest-dev)\n * - vite or vite/* (we use our core package)\n * - Node.js built-ins\n * - Blocklisted packages\n * - Relative paths\n */\nfunction isLeafDependency(specifier: string): boolean {\n  // Relative paths\n  if (specifier.startsWith('.') || specifier.startsWith('/')) {\n    return false;\n  }\n  // @vitest/* packages (we copy these)\n  if (specifier.startsWith('@vitest/')) {\n    return false;\n  }\n  // vitest or vitest/* (we copy vitest-dev)\n  if (specifier === 'vitest' || specifier.startsWith('vitest/')) {\n    return false;\n  }\n  // vite or vite/* (we use our core package)\n  if (specifier === 'vite' || specifier.startsWith('vite/')) {\n    return false;\n  }\n  // Node.js built-ins\n  if (NODE_BUILTINS.has(specifier)) {\n    return false;\n  }\n  // Blocklisted packages\n  if (EXTERNAL_BLOCKLIST.has(specifier)) {\n    return false;\n  }\n  // Node.js subpath imports (#module-evaluator, etc.)\n  if (specifier.startsWith('#')) {\n    return false;\n  }\n  // Invalid specifiers\n  if (!/^(@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*/.test(specifier)) {\n    return false;\n  }\n  return true;\n}\n\n/**\n * Bundle only leaf dependencies into dist/vendor/.\n * Only bundles non-@vitest deps (tinyrainbow, pathe, chai, etc.)\n * to avoid shared chunks that mix Node.js and browser code.\n */\nasync function bundleLeafDeps(leafDeps: Set<string>): Promise<Map<string, string>> {\n  console.log('\\nBundling leaf dependencies...');\n\n  await rm(vendorDir, { recursive: true, force: true });\n  await mkdir(vendorDir, { recursive: true });\n\n  const specifierToVendorPath = new Map<string, string>();\n\n  if (leafDeps.size === 0) {\n    console.log('  No leaf dependencies to bundle.');\n    return specifierToVendorPath;\n  }\n\n  // Build input object with all leaf deps\n  const input: Record<string, string> = {};\n  for (const dep of leafDeps) {\n    const safeName = safeFileName(dep);\n    input[safeName] = dep;\n  }\n\n  try {\n    await build({\n      input,\n      output: {\n        dir: vendorDir,\n        format: 'esm',\n        entryFileNames: '[name].mjs',\n        chunkFileNames: 'shared-[hash].mjs',\n      },\n      platform: 'neutral',\n      treeshake: false,\n      external: [\n        // Keep node built-ins external\n        ...NODE_BUILTINS,\n        // Keep blocklisted packages external\n        ...EXTERNAL_BLOCKLIST,\n        // Keep @vitest/* external (we copy them)\n        /@vitest\\//,\n        // Keep vitest external (we copy it)\n        /^vitest(\\/.*)?$/,\n        // Keep vite external (we use core package)\n        /^vite(\\/.*)?$/,\n      ],\n      resolve: {\n        conditionNames: ['node', 'import', 'default'],\n        mainFields: ['module', 'main'],\n      },\n      logLevel: 'warn',\n    });\n\n    const dtsInput = { ...input };\n\n    for (const name of Object.keys(dtsInput)) {\n      const vendorDtsPath = join(vendorDir, `vendor_${name}.d.ts`);\n      dtsInput[name] = vendorDtsPath;\n      await writeFile(vendorDtsPath, `export * from '${name}';`, 'utf-8');\n    }\n\n    await build({\n      input: dtsInput,\n      output: {\n        dir: vendorDir,\n        format: 'esm',\n        entryFileNames: '[name].mts',\n      },\n      plugins: [\n        dts({\n          dtsInput: true,\n          oxc: true,\n          resolver: 'oxc',\n          emitDtsOnly: true,\n          tsconfig: false,\n        }),\n      ],\n    });\n\n    for (const p of Object.values(dtsInput)) {\n      await rm(p);\n    }\n\n    // Register all specifiers\n    for (const dep of leafDeps) {\n      const safeName = safeFileName(dep);\n      const vendorFilePath = join(vendorDir, `${safeName}.mjs`);\n      specifierToVendorPath.set(dep, vendorFilePath);\n      console.log(`  -> vendor/${safeName}.mjs`);\n\n      // Fix CJS packages that need named exports extracted from default\n      if (CJS_REEXPORT_PACKAGES.has(dep)) {\n        await fixCjsNamedExports(vendorFilePath, dep);\n      }\n    }\n  } catch (error) {\n    console.error('Failed to bundle leaf dependencies:', error);\n    throw error;\n  }\n\n  console.log(`\\nBundled ${specifierToVendorPath.size} leaf dependencies.`);\n  return specifierToVendorPath;\n}\n\n/**\n * Rewrite imports in all copied @vitest/* files and vitest-dev dist files.\n * This handles:\n * - @vitest/* -> relative paths to dist/@vitest/\n * - vitest/* -> relative paths to dist/\n * - vite -> @voidzero-dev/vite-plus-core\n * - leaf deps -> relative paths to dist/vendor/\n */\nasync function rewriteVitestImports(leafDepToVendorPath: Map<string, string>) {\n  console.log('\\nRewriting imports in @vitest/* and vitest core files...');\n\n  const vitestDir = resolve(distDir, '@vitest');\n  let rewrittenCount = 0;\n\n  // Scan both @vitest/* packages AND vitest core dist files\n  // Include .d.ts files so TypeScript type imports also get rewritten\n  const jsFiles = fsGlob([\n    join(vitestDir, '**/*.js'),\n    join(vitestDir, '**/*.d.ts'),\n    join(distDir, '*.js'),\n    join(distDir, '*.d.ts'),\n    join(distDir, 'chunks/*.js'),\n    join(distDir, 'chunks/*.d.ts'),\n  ]);\n\n  for await (const file of jsFiles) {\n    let content = await readFile(file, 'utf-8');\n    const fileDir = dirname(file);\n\n    // Build specifier map for this file\n    const specifierMap = new Map<string, string>();\n\n    // Add @vitest/* mappings (relative paths)\n    for (const [pkg, destPath] of Object.entries(VITEST_PACKAGE_TO_PATH)) {\n      const absoluteDest = resolve(distDir, destPath);\n      let relativePath = relative(fileDir, absoluteDest);\n      relativePath = relativePath.split('\\\\').join('/'); // Windows fix\n      if (!relativePath.startsWith('.')) {\n        relativePath = './' + relativePath;\n      }\n      specifierMap.set(pkg, absoluteDest);\n    }\n\n    // Add vitest/* mappings (relative to dist/)\n    const vitestSubpathRewrites: Record<string, string> = {\n      vitest: resolve(distDir, 'index.js'),\n      'vitest/node': resolve(distDir, 'node.js'),\n      'vitest/config': resolve(distDir, 'config.js'),\n      // vitest/browser exports page, server, CDPSession, BrowserCommands, etc from @vitest/browser/context\n      // This matches vitest's own package.json exports: \"./browser\" -> \"./browser/context.d.ts\"\n      'vitest/browser': resolve(distDir, '@vitest/browser/context.js'),\n      // vitest/internal/browser exports browser-safe __INTERNAL and stringify (NOT @vitest/browser/index.js which has Node.js code)\n      'vitest/internal/browser': resolve(distDir, 'browser.js'),\n      'vitest/runners': resolve(distDir, 'runners.js'),\n      'vitest/suite': resolve(distDir, 'suite.js'),\n      'vitest/environments': resolve(distDir, 'environments.js'),\n      'vitest/coverage': resolve(distDir, 'coverage.js'),\n      'vitest/reporters': resolve(distDir, 'reporters.js'),\n      'vitest/snapshot': resolve(distDir, 'snapshot.js'),\n      'vitest/mocker': resolve(distDir, 'mocker.js'),\n    };\n    for (const [specifier, absolutePath] of Object.entries(vitestSubpathRewrites)) {\n      specifierMap.set(specifier, absolutePath);\n    }\n\n    // Add leaf dep mappings (relative to vendor/)\n    for (const [specifier, vendorPath] of leafDepToVendorPath) {\n      specifierMap.set(specifier, vendorPath);\n    }\n\n    // For files inside @vitest/browser/, preserve 'vitest/browser' as a bare specifier.\n    // These files run in browser context where the vitest:vendor-aliases plugin\n    // resolves 'vitest/browser' to the virtual module '\\0vitest/browser',\n    // which provides browser-safe context API (page, server, userEvent, utils).\n    // Without this, 'vitest/browser' gets rewritten to './index.js' which resolves\n    // to the Node.js server file (~9000 lines of node:fs, ws, etc.)\n    if (file.includes('@vitest/browser') || file.includes('@vitest\\\\browser')) {\n      specifierMap.delete('vitest/browser');\n    }\n\n    // Rewrite using AST\n    const rewritten = rewriteImportsWithAst(content, file, false, specifierMap);\n\n    // Also rewrite vite -> core package (simple string replacement since it's a package name)\n    let finalContent = rewritten\n      .replaceAll(/from ['\"]vite['\"]/g, `from '${CORE_PACKAGE_NAME}'`)\n      .replaceAll(/import\\(['\"]vite['\"]\\)/g, `import('${CORE_PACKAGE_NAME}')`)\n      .replaceAll(`'vite/module-runner'`, `'${CORE_PACKAGE_NAME}/module-runner'`);\n\n    // Special handling for @vitest/mocker entry files that have redundant side-effect imports\n    // The original files have: import 'magic-string'; export {...} from './chunk-automock.js'; import 'estree-walker';\n    // This is problematic because:\n    // 1. Side-effect imports are redundant (chunk files already import what they need)\n    // 2. Having imports after exports can confuse some module parsers\n    // Fix: Remove redundant side-effect imports from vendor deps in entry files\n    if (file.includes('@vitest/mocker') || file.includes('@vitest\\\\mocker')) {\n      // Get the base filename\n      const baseName = file.split(/[/\\\\]/).pop();\n      // Only process entry files (not chunk files)\n      if (baseName && !baseName.startsWith('chunk-')) {\n        // Remove side-effect imports from vendor deps (these are redundant since chunk files import them)\n        finalContent = finalContent.replace(/import\\s*['\"][^'\"]*vendor[^'\"]*\\.mjs['\"];?\\s*/g, '');\n      }\n    }\n\n    if (finalContent !== content) {\n      await writeFile(file, finalContent, 'utf-8');\n      rewrittenCount++;\n    }\n  }\n\n  console.log(`  Rewrote imports in ${rewrittenCount} files`);\n\n  // Also rewrite imports in the main vitest-dev dist files\n  console.log('\\nRewriting imports in vitest-dev dist files...');\n  let mainRewrittenCount = 0;\n\n  const mainJsFiles = fsGlob(join(distDir, '*.js'));\n  const chunksJsFiles = fsGlob(join(distDir, 'chunks', '*.js'));\n  const workersJsFiles = fsGlob(join(distDir, 'workers', '*.js'));\n\n  for await (const file of mainJsFiles) {\n    const rewritten = await rewriteDistFile(file, leafDepToVendorPath);\n    if (rewritten) {\n      mainRewrittenCount++;\n    }\n  }\n  for await (const file of chunksJsFiles) {\n    const rewritten = await rewriteDistFile(file, leafDepToVendorPath);\n    if (rewritten) {\n      mainRewrittenCount++;\n    }\n  }\n  for await (const file of workersJsFiles) {\n    const rewritten = await rewriteDistFile(file, leafDepToVendorPath);\n    if (rewritten) {\n      mainRewrittenCount++;\n    }\n  }\n\n  console.log(`  Rewrote imports in ${mainRewrittenCount} dist files`);\n}\n\n/**\n * Rewrite imports in a vitest-dev dist file.\n * Returns true if the file was modified.\n */\nasync function rewriteDistFile(\n  file: string,\n  leafDepToVendorPath: Map<string, string>,\n): Promise<boolean> {\n  let content = await readFile(file, 'utf-8');\n\n  // Build specifier map\n  const specifierMap = new Map<string, string>();\n\n  // Add @vitest/* mappings\n  for (const [pkg, destPath] of Object.entries(VITEST_PACKAGE_TO_PATH)) {\n    const absoluteDest = resolve(distDir, destPath);\n    specifierMap.set(pkg, absoluteDest);\n  }\n\n  // Add leaf dep mappings\n  for (const [specifier, vendorPath] of leafDepToVendorPath) {\n    specifierMap.set(specifier, vendorPath);\n  }\n\n  // Add vitest/* subpath mappings\n  // NOTE: Do NOT include 'vitest/browser' - it must be handled by\n  // the vitest:browser:virtual-module:context plugin at runtime\n  const vitestSubpathRewrites: Record<string, string> = {\n    vitest: resolve(distDir, 'index.js'),\n    'vitest/node': resolve(distDir, 'node.js'),\n    'vitest/config': resolve(distDir, 'config.js'),\n    // 'vitest/browser' - intentionally omitted, handled by virtual module plugin\n    'vitest/internal/browser': resolve(distDir, 'browser.js'),\n    'vitest/runners': resolve(distDir, 'runners.js'),\n    'vitest/suite': resolve(distDir, 'suite.js'),\n    'vitest/environments': resolve(distDir, 'environments.js'),\n    'vitest/coverage': resolve(distDir, 'coverage.js'),\n    'vitest/reporters': resolve(distDir, 'reporters.js'),\n    'vitest/snapshot': resolve(distDir, 'snapshot.js'),\n    'vitest/mocker': resolve(distDir, 'mocker.js'),\n  };\n  for (const [specifier, absolutePath] of Object.entries(vitestSubpathRewrites)) {\n    specifierMap.set(specifier, absolutePath);\n  }\n\n  // Add mappings for ./vendor/vitest_*.mjs relative imports\n  // These are vitest-dev's bundled @vitest/* packages that we've copied to dist/@vitest/\n  const vendorToVitest: Record<string, string> = {\n    './vendor/vitest_runner.mjs': resolve(distDir, '@vitest/runner/index.js'),\n    './vendor/vitest_runners.mjs': resolve(distDir, 'runners.js'),\n    './vendor/vitest_browser.mjs': resolve(distDir, '@vitest/browser/context.js'),\n    './vendor/vitest_internal_browser.mjs': resolve(distDir, 'browser.js'),\n    './vendor/vitest_utils.mjs': resolve(distDir, '@vitest/utils/index.js'),\n    './vendor/vitest_spy.mjs': resolve(distDir, '@vitest/spy/index.js'),\n    './vendor/vitest_snapshot.mjs': resolve(distDir, '@vitest/snapshot/index.js'),\n    './vendor/vitest_expect.mjs': resolve(distDir, '@vitest/expect/index.js'),\n  };\n  for (const [vendorPath, destPath] of Object.entries(vendorToVitest)) {\n    specifierMap.set(vendorPath, destPath);\n  }\n\n  let rewritten = rewriteImportsWithAst(content, file, false, specifierMap);\n\n  // Strip module-runner side-effect import from index.js\n  // This import is Node.js-only and causes browser tests to hang when vitest/index.js\n  // is loaded in browser context (to get describe, it, expect, etc.)\n  // The module-runner contains Node.js code (process.platform, etc.) that browsers can't execute\n  if (basename(file) === 'index.js') {\n    rewritten = rewritten.replace(\n      /import\\s*['\"]@voidzero-dev\\/vite-plus-core\\/module-runner['\"];?\\s*/g,\n      '',\n    );\n  }\n\n  if (rewritten !== content) {\n    await writeFile(file, rewritten, 'utf-8');\n    return true;\n  }\n  return false;\n}\n\n/**\n * Rewrite imports using oxc-parser AST for precise replacements\n */\nfunction rewriteImportsWithAst(\n  content: string,\n  filePath: string,\n  isCjs: boolean,\n  specifierToVendorPath: Map<string, string>,\n): string {\n  // Use Map to deduplicate replacements by start position\n  const replacementMap = new Map<number, [number, number, string]>();\n\n  // Helper to get relative path for a specifier\n  const getRelativePath = (specifier: string): string | null => {\n    const vendorPath = specifierToVendorPath.get(specifier);\n    if (!vendorPath) {\n      return null;\n    }\n    let relativePath = relative(dirname(filePath), vendorPath);\n    // Normalize to forward slashes for ES module imports (Windows uses backslashes)\n    relativePath = relativePath.split('\\\\').join('/');\n    if (!relativePath.startsWith('.')) {\n      relativePath = './' + relativePath;\n    }\n    return relativePath;\n  };\n\n  // Helper to add replacement (deduplicates by start position)\n  const addReplacement = (start: number, end: number, newValue: string) => {\n    if (!replacementMap.has(start)) {\n      replacementMap.set(start, [start, end, newValue]);\n    }\n  };\n\n  // Parse with oxc-parser\n  const result = parseSync(filePath, content, {\n    sourceType: isCjs ? 'script' : 'module',\n  });\n\n  // Collect ESM static imports\n  for (const imp of result.module.staticImports) {\n    const specifier = imp.moduleRequest.value;\n    const relativePath = getRelativePath(specifier);\n    if (relativePath) {\n      // Replace the module request (including quotes)\n      addReplacement(imp.moduleRequest.start, imp.moduleRequest.end, `'${relativePath}'`);\n    }\n  }\n\n  // Collect ESM static exports (re-exports)\n  for (const exp of result.module.staticExports) {\n    for (const entry of exp.entries) {\n      if (entry.moduleRequest) {\n        const specifier = entry.moduleRequest.value;\n        const relativePath = getRelativePath(specifier);\n        if (relativePath) {\n          addReplacement(entry.moduleRequest.start, entry.moduleRequest.end, `'${relativePath}'`);\n        }\n      }\n    }\n  }\n\n  // Collect dynamic imports (only string literals)\n  for (const dynImp of result.module.dynamicImports) {\n    const rawText = content.slice(dynImp.moduleRequest.start, dynImp.moduleRequest.end);\n    if (\n      (rawText.startsWith(\"'\") && rawText.endsWith(\"'\")) ||\n      (rawText.startsWith('\"') && rawText.endsWith('\"'))\n    ) {\n      const specifier = rawText.slice(1, -1);\n      const relativePath = getRelativePath(specifier);\n      if (relativePath) {\n        addReplacement(dynImp.moduleRequest.start, dynImp.moduleRequest.end, `'${relativePath}'`);\n      }\n    }\n  }\n\n  // For CJS files, also handle require() calls using regex (oxc-parser doesn't track these)\n  if (isCjs) {\n    const requireRegex = /require\\s*\\(\\s*(['\"])([^'\"]+)\\1\\s*\\)/g;\n    let match;\n    while ((match = requireRegex.exec(content)) !== null) {\n      const specifier = match[2];\n      const relativePath = getRelativePath(specifier);\n      if (relativePath) {\n        // Calculate the position of just the string literal (including quotes)\n        const stringStart = match.index + match[0].indexOf(match[1]);\n        const stringEnd = stringStart + match[1].length + specifier.length + match[1].length;\n        addReplacement(stringStart, stringEnd, `'${relativePath}'`);\n      }\n    }\n  }\n\n  // Sort replacements in reverse order (end to start) to preserve positions\n  // eslint-disable-next-line unicorn/no-array-sort -- safe: sorting a fresh spread copy\n  const replacements = [...replacementMap.values()].sort((a, b) => b[0] - a[0]);\n\n  // Apply replacements\n  let result_content = content;\n  for (const [start, end, newValue] of replacements) {\n    result_content = result_content.slice(0, start) + newValue + result_content.slice(end);\n  }\n\n  return result_content;\n}\n\n/**\n * Fix CJS packages that only export default - extract named exports from the default export\n */\nasync function fixCjsNamedExports(vendorFilePath: string, specifier: string) {\n  let content = await readFile(vendorFilePath, 'utf-8');\n\n  // Match pattern like: export default require_xxx();\n  // and: export {  };\n  const defaultExportMatch = content.match(/export default (require_\\w+)\\(\\);/);\n\n  if (defaultExportMatch) {\n    const requireFn = defaultExportMatch[1];\n    console.log(`      Fixing CJS named exports for ${specifier}...`);\n\n    const emptyExportMatch = content.match(/export \\{\\s*\\};/);\n    if (emptyExportMatch) {\n      // Pattern: export default require_xxx();\\nexport {  };\n      content = content.replace(\n        /export default (require_\\w+)\\(\\);\\s*\\nexport \\{\\s*\\};/,\n        `const __cjs_export__ = ${requireFn}();\\nexport const { expectTypeOf } = __cjs_export__;\\nexport default __cjs_export__;`,\n      );\n    } else {\n      // Pattern: export default require_xxx(); (no empty export block)\n      content = content.replace(\n        /export default (require_\\w+)\\(\\);/,\n        `const __cjs_export__ = ${requireFn}();\\nexport const { expectTypeOf } = __cjs_export__;\\nexport default __cjs_export__;`,\n      );\n    }\n\n    await writeFile(vendorFilePath, content, 'utf-8');\n  }\n}\n\n/**\n * Create a safe filename from a specifier\n */\nfunction safeFileName(specifier: string): string {\n  return specifier.replace(/[@/]/g, '_').replace(/^_/, '');\n}\n\n/**\n * Patch pkgRoot/distRoot paths in vendor files.\n * The bundled code assumes files are in dist/, but vendor files are in dist/vendor/\n * So \"../..\" needs to become \"../../..\" to correctly resolve to package root.\n * Also patches relative file references like \"context.js\" to \"../context.js\".\n */\nasync function patchVendorPaths() {\n  console.log('\\nPatching vendor paths...');\n\n  // Patterns that need one more level up due to vendor subdirectory\n  const pathPatterns = [\n    // Package root calculation: \"../..\" -> \"../../..\"\n    {\n      original: `resolve$1(fileURLToPath(import.meta.url), \"../..\")`,\n      fixed: `resolve$1(fileURLToPath(import.meta.url), \"../../..\")`,\n    },\n    // context.js reference: \"context.js\" -> \"../context.js\"\n    // This is used in browser server to resolve the vitest/browser/context export\n    {\n      original: `resolve$1(__dirname$1, \"context.js\")`,\n      fixed: `resolve$1(__dirname$1, \"../context.js\")`,\n    },\n  ];\n\n  const vendorFiles = fsGlob(join(vendorDir, '*.mjs'));\n  let patchedCount = 0;\n\n  for await (const file of vendorFiles) {\n    let content = await readFile(file, 'utf-8');\n    let modified = false;\n\n    for (const { original, fixed } of pathPatterns) {\n      if (content.includes(original)) {\n        content = content.replaceAll(original, fixed);\n        modified = true;\n      }\n    }\n\n    if (modified) {\n      await writeFile(file, content, 'utf-8');\n      console.log(`  Patched paths in ${relative(distDir, file)}`);\n      patchedCount++;\n    }\n  }\n\n  if (patchedCount === 0) {\n    console.log('  No vendor files needed path patching');\n  } else {\n    console.log(`  Successfully patched ${patchedCount} file(s)`);\n  }\n}\n\n/**\n * Patch VitestCoreResolver to resolve vite-plus/test directly.\n *\n * Problem: CLI's `export * from '@voidzero-dev/vite-plus-test'` creates a re-export\n * chain that breaks module identity in Vite's SSR transform. expect.extend()\n * mutations aren't visible through the re-export.\n *\n * Fix: Make VitestCoreResolver resolve both vite-plus/test and\n * @voidzero-dev/vite-plus-test directly to dist/index.js, bypassing re-exports.\n */\nasync function patchVitestCoreResolver() {\n  console.log('\\nPatching VitestCoreResolver for CLI package alias...');\n\n  let cliApiChunk: string | undefined;\n  for await (const chunk of fsGlob(join(distDir, 'chunks/cli-api.*.js'))) {\n    cliApiChunk = chunk;\n    break;\n  }\n\n  if (!cliApiChunk) {\n    throw new Error('cli-api chunk not found');\n  }\n  let content = await readFile(cliApiChunk, 'utf8');\n\n  // Find the VitestCoreResolver resolveId function and add our package aliases\n  const oldPattern = `async resolveId(id) {\n      if (id === \"vitest\") return resolve(distDir, \"index.js\");\n      if (id.startsWith(\"@vitest/\") || id.startsWith(\"vitest/\"))`;\n\n  const newCode = `async resolveId(id) {\n      if (id === \"vitest\") return resolve(distDir, \"index.js\");\n      // Resolve CLI test path and test package directly to dist/index.js\n      // This bypasses the re-export chain and ensures module identity is preserved\n      if (id === \"vite-plus/test\" || id === \"@voidzero-dev/vite-plus-test\") {\n        return resolve(distDir, \"index.js\");\n      }\n      // Handle subpaths: vite-plus/test/* -> vitest/*\n      if (id.startsWith(\"vite-plus/test/\")) {\n        const subpath = id.slice(\"vite-plus/test/\".length);\n        return this.resolve(\"vitest/\" + subpath, join(ctx.config.root, \"index.html\"), { skipSelf: true });\n      }\n      // Handle subpaths: @voidzero-dev/vite-plus-test/* -> vitest/*\n      if (id.startsWith(\"@voidzero-dev/vite-plus-test/\")) {\n        const subpath = id.slice(\"@voidzero-dev/vite-plus-test/\".length);\n        return this.resolve(\"vitest/\" + subpath, join(ctx.config.root, \"index.html\"), { skipSelf: true });\n      }\n      if (id.startsWith(\"@vitest/\") || id.startsWith(\"vitest/\"))`;\n\n  if (!content.includes(oldPattern)) {\n    throw new Error(\n      'Could not find VitestCoreResolver pattern to patch in ' +\n        cliApiChunk +\n        '. ' +\n        'This likely means vitest code has changed and the patch needs to be updated.',\n    );\n  }\n\n  content = content.replace(oldPattern, newCode);\n  await writeFile(cliApiChunk, content);\n  console.log('  Patched VitestCoreResolver to resolve vite-plus/test directly');\n}\n\n/**\n * Convert tabs to spaces in all JS files in dist/ for consistent formatting.\n * This allows our patching code to use space-based patterns instead of tabs.\n */\nasync function convertTabsToSpaces() {\n  console.log('\\nConverting tabs to spaces in dist/...');\n\n  let convertedCount = 0;\n\n  for await (const file of fsGlob(resolve(distDir, '**/*.js'))) {\n    const content = await readFile(file, 'utf-8');\n    if (content.includes('\\t')) {\n      const converted = content.replace(/\\t/g, '  ');\n      await writeFile(file, converted);\n      convertedCount++;\n    }\n  }\n\n  console.log(`  Converted ${convertedCount} files`);\n}\n\n/**\n * Fix pkgRoot path resolution in all `@vitest/*` packages.\n * The original packages use resolve(import.meta.url, \"../..\") to find their package root.\n * But our files are at `dist/@vitest/star/index.js`, so we need to go up 3 levels, not 2.\n */\nasync function patchVitestPkgRootPaths() {\n  console.log('\\nFixing distRoot paths in @vitest/* packages...');\n\n  const vitestDir = resolve(distDir, '@vitest');\n  let patchedCount = 0;\n\n  for (const pkg of VITEST_PACKAGES_TO_COPY) {\n    const pkgName = pkg.replace('@vitest/', '');\n    const indexPath = join(vitestDir, pkgName, 'index.js');\n\n    try {\n      await stat(indexPath);\n    } catch {\n      continue;\n    }\n\n    let content = await readFile(indexPath, 'utf-8');\n\n    // The original @vitest/browser had index.js in the dist/ folder, so:\n    //   pkgRoot = resolve(import.meta.url, \"../..\") -> @vitest/browser\n    //   distRoot = resolve(pkgRoot, \"dist\") -> @vitest/browser/dist\n    // But our file is at dist/@vitest/browser/index.js, so distRoot should just be\n    // the directory containing index.js (not pkgRoot/dist)\n    // Replace both lines with just making distRoot = dirname of index.js\n    // Use regex to handle both top-level and indented occurrences\n    const oldPattern =\n      /( *)const pkgRoot = resolve\\(fileURLToPath\\(import\\.meta\\.url\\), \"\\.\\.\\/\\.\\.\"\\);\\n\\1const distRoot = resolve\\(pkgRoot, \"dist\"\\);/g;\n    const newContent = content.replace(\n      oldPattern,\n      '$1const distRoot = dirname(fileURLToPath(import.meta.url));',\n    );\n\n    if (newContent !== content) {\n      await writeFile(indexPath, newContent, 'utf-8');\n      const matchCount = (content.match(oldPattern) || []).length;\n      console.log(`  Fixed ${pkg}/index.js (${matchCount} occurrences)`);\n      patchedCount++;\n    }\n  }\n\n  console.log(`  Patched ${patchedCount} packages`);\n}\n\n/**\n * Patch the copied @vitest/browser package to:\n * 1. Inject vitest:vendor-aliases plugin for @vitest/* resolution\n * 2. Add native deps to the exclude list\n * 3. Remove include patterns for bundled deps\n */\nasync function patchVitestBrowserPackage() {\n  console.log('\\nPatching @vitest/browser package...');\n\n  const browserIndexPath = join(distDir, '@vitest/browser/index.js');\n\n  try {\n    await stat(browserIndexPath);\n  } catch {\n    console.log('  Warning: @vitest/browser not found in dist, skipping');\n    return;\n  }\n\n  let content = await readFile(browserIndexPath, 'utf-8');\n\n  // 1. Inject vitest:vendor-aliases plugin into BrowserPlugin return array\n  // This allows imports like @vitest/runner to be resolved to our copied @vitest files\n  // Exclude @vitest/browser/context from vendor-aliases so that BrowserContext\n  // plugin's resolveId can intercept the bare specifier and return the virtual\n  // module (which includes the dynamically generated `server` export).\n  // Without this, vendor-aliases resolves the bare specifier to the static\n  // context.js file (which has no `server`), bypassing BrowserContext entirely.\n  // See: https://github.com/voidzero-dev/vite-plus/issues/1086\n  const VENDOR_ALIASES_EXCLUDE = new Set(['@vitest/browser/context']);\n\n  const mappingEntries = Object.entries(VITEST_PACKAGE_TO_PATH)\n    .filter(([pkg]) => pkg.startsWith('@vitest/') && !VENDOR_ALIASES_EXCLUDE.has(pkg))\n    .map(([pkg, file]) => `'${pkg}': resolve(packageRoot, '${file}')`)\n    .join(',\\n      ');\n\n  // distRoot is @vitest/browser/ so we need to go up two levels to reach the actual dist root\n  const vendorAliasesPlugin = `{\n    name: 'vitest:vendor-aliases',\n    enforce: 'pre',\n    resolveId(id) {\n      // distRoot is @vitest/browser/, packageRoot is the actual dist/ directory\n      const packageRoot = resolve(distRoot, '../..');\n      // Resolve module-runner to a browser-safe stub\n      // This is critical: module-runner contains Node.js-only code (process.platform, etc.)\n      // that causes browsers to hang when loaded\n      if (id === '${CORE_PACKAGE_NAME}/module-runner' || id === 'vite/module-runner') {\n        return resolve(packageRoot, 'module-runner-stub.js');\n      }\n      // Mark vite/core as external to prevent Node.js-only code from being bundled\n      // This prevents __vite__injectQuery duplication errors in browser tests\n      if (id === '${CORE_PACKAGE_NAME}' || id === 'vite') {\n        return { id, external: true };\n      }\n      // Handle vitest/browser and package aliases\n      // Return virtual module ID so BrowserContext plugin can load it\n      // Supports: vitest/browser, @voidzero-dev/vite-plus-test/browser, vite-plus/test/browser\n      if (id === 'vitest/browser' || id === '@voidzero-dev/vite-plus-test/browser' || id === 'vite-plus/test/browser') {\n        return '\\\\0vitest/browser';\n      }\n      // Handle vitest/* subpaths (resolve to our dist files)\n      // Also handle @voidzero-dev package aliases that resolve to the same files\n      const vitestSubpathMap = {\n        'vitest': resolve(packageRoot, 'index.js'),\n        '@voidzero-dev/vite-plus-test': resolve(packageRoot, 'index.js'),\n        'vite-plus/test': resolve(packageRoot, 'index.js'),\n        'vitest/node': resolve(packageRoot, 'node.js'),\n        'vitest/config': resolve(packageRoot, 'config.js'),\n        'vitest/internal/browser': resolve(packageRoot, 'browser.js'),\n        'vitest/runners': resolve(packageRoot, 'runners.js'),\n        'vitest/suite': resolve(packageRoot, 'suite.js'),\n        'vitest/environments': resolve(packageRoot, 'environments.js'),\n        'vitest/coverage': resolve(packageRoot, 'coverage.js'),\n        'vitest/reporters': resolve(packageRoot, 'reporters.js'),\n        'vitest/snapshot': resolve(packageRoot, 'snapshot.js'),\n        'vitest/mocker': resolve(packageRoot, 'mocker.js'),\n        // Browser providers - resolve to our bundled @vitest/browser-* packages\n        'vitest/browser-playwright': resolve(packageRoot, '@vitest/browser-playwright/index.js'),\n        'vitest/browser-webdriverio': resolve(packageRoot, '@vitest/browser-webdriverio/index.js'),\n        'vitest/browser-preview': resolve(packageRoot, '@vitest/browser-preview/index.js'),\n      };\n      if (vitestSubpathMap[id]) {\n        return vitestSubpathMap[id];\n      }\n      // Handle @voidzero-dev/vite-plus-test/* subpaths (same as vitest/*)\n      if (id.startsWith('@voidzero-dev/vite-plus-test/')) {\n        const subpath = id.slice('@voidzero-dev/vite-plus-test/'.length);\n        const vitestEquiv = 'vitest/' + subpath;\n        if (vitestSubpathMap[vitestEquiv]) {\n          return vitestSubpathMap[vitestEquiv];\n        }\n      }\n      // Handle vite-plus/test/* subpaths (CLI package paths, same as vitest/*)\n      if (id.startsWith('vite-plus/test/')) {\n        const subpath = id.slice('vite-plus/test/'.length);\n        const vitestEquiv = 'vitest/' + subpath;\n        if (vitestSubpathMap[vitestEquiv]) {\n          return vitestSubpathMap[vitestEquiv];\n        }\n      }\n      // Handle @vitest/* packages (resolve to our copied files)\n      const vendorMap = {\n      ${mappingEntries}\n      };\n      if (vendorMap[id]) {\n        return vendorMap[id];\n      }\n    }\n  }`;\n\n  // Find BrowserPlugin return array and inject plugin\n  const pluginArrayPattern = /(return \\[)(\\n +\\{\\n +enforce: \"pre\",\\n +name: \"vitest:browser\",)/;\n  if (pluginArrayPattern.test(content)) {\n    content = content.replace(pluginArrayPattern, `$1\\n    ${vendorAliasesPlugin},$2`);\n    console.log('  Injected vitest:vendor-aliases plugin');\n  } else {\n    throw new Error(\n      'Failed to inject vendor-aliases plugin in @vitest/browser/index.js: pattern not found. ' +\n        'This likely means vitest code has changed and the patch needs to be updated.',\n    );\n  }\n\n  // 2. Patch exclude list to add native deps\n  // Pattern: const exclude = [\"vitest\", ...\n  const excludePattern = /(const exclude = \\[)(\\n?\\s*\"vitest\",)/;\n  // Exclude packages that:\n  // Packages to exclude from Vite's dependency pre-bundling (optimizeDeps.exclude)\n  const packagesToExclude = [\n    // @vitest packages that need our resolveId plugin\n    '@vitest/browser',\n    '@vitest/ui',\n    '@vitest/ui/reporter',\n    '@vitest/mocker/node', // imports @voidzero-dev/vite-plus-core\n\n    // Our package aliases - preserve module identity with init scripts\n    // This ensures both init scripts (loaded via /@fs/) and tests use the same page singleton\n    '@voidzero-dev/vite-plus-test',\n    '@voidzero-dev/vite-plus-test/browser',\n    '@voidzero-dev/vite-plus-test/browser/context',\n    'vite-plus/test',\n    'vite-plus/test/browser',\n    'vite-plus/test/browser/context',\n\n    // Node.js only packages\n    'vite',\n    '@voidzero-dev/vite-plus-core',\n    '@voidzero-dev/vite-plus-core/module-runner',\n\n    // Native bindings\n    'lightningcss',\n    '@tailwindcss/oxide',\n    'tailwindcss', // pulls in @tailwindcss/oxide\n  ];\n\n  const excludeListStr = packagesToExclude.map((pkg) => `\"${pkg}\"`).join(',\\n          ');\n  const excludeReplacement = `$1\\n          ${excludeListStr},$2`;\n  if (excludePattern.test(content)) {\n    content = content.replace(excludePattern, excludeReplacement);\n    console.log('  Patched exclude list with native deps');\n  } else {\n    throw new Error(\n      'Failed to patch exclude list in @vitest/browser/index.js: pattern not found. ' +\n        'This likely means vitest code has changed and the patch needs to be updated.',\n    );\n  }\n\n  // 3. Remove include patterns that reference bundled deps\n  // These patterns like \"vitest > expect-type\" don't work with our bundled setup\n  // since the deps are already bundled into vendor files\n  const includePatterns = [\n    '\"vitest > expect-type\"',\n    '\"vitest > @vitest/snapshot > magic-string\"',\n    '\"vitest > @vitest/expect > chai\"',\n  ];\n  for (const pattern of includePatterns) {\n    content = content.replace(\n      new RegExp(`\\\\s*${pattern.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')},?`, 'g'),\n      '',\n    );\n  }\n  console.log('  Removed bundled deps from include list');\n\n  // 4. Patch BrowserContext to also handle our package aliases as fallback\n  // This allows direct imports from our package without requiring vitest override\n  // Supports: vitest/browser, @voidzero-dev/vite-plus-test/browser, vite-plus/test/browser\n  const browserContextPattern = /if \\(id === ID_CONTEXT\\) \\{/;\n  if (browserContextPattern.test(content)) {\n    content = content.replace(\n      browserContextPattern,\n      `if (id === ID_CONTEXT || id === \"@voidzero-dev/vite-plus-test/browser\" || id === \"vite-plus/test/browser\") {`,\n    );\n    console.log('  Patched BrowserContext to handle package aliases');\n  } else {\n    throw new Error(\n      'Failed to patch BrowserContext in @vitest/browser/index.js: pattern not found. ' +\n        'This likely means vitest code has changed and the patch needs to be updated.',\n    );\n  }\n\n  await writeFile(browserIndexPath, content, 'utf-8');\n  console.log('  Successfully patched @vitest/browser/index.js');\n}\n\n/**\n * Patch browser provider locators.js files to use browser-safe imports.\n *\n * The original files import from '../browser/index.js' which includes Node.js server code.\n * We need to change them to import from browser-safe files instead.\n *\n * Providers handled:\n *   - @vitest/browser-playwright: import { page, server } from '../browser/index.js';\n *   - @vitest/browser-webdriverio: import { page, server, utils } from '../browser/index.js';\n *   - @vitest/browser-preview: import { page, server, utils, userEvent } from '../browser/index.js';\n */\nasync function patchBrowserProviderLocators() {\n  console.log('\\nPatching browser provider locators.js files...');\n\n  const providers = [\n    { name: 'browser-playwright', extraImports: [] as string[] },\n    { name: 'browser-webdriverio', extraImports: ['utils'] },\n    { name: 'browser-preview', extraImports: ['utils', 'userEvent'] },\n  ];\n\n  for (const provider of providers) {\n    const locatorsPath = join(distDir, `@vitest/${provider.name}/locators.js`);\n\n    try {\n      await stat(locatorsPath);\n    } catch {\n      console.log(`  Warning: @vitest/${provider.name}/locators.js not found, skipping`);\n      continue;\n    }\n\n    let content = await readFile(locatorsPath, 'utf-8');\n    let patched = false;\n\n    // 1. Patch the vitest/browser import to separate page (from context.js) and other imports\n    // After rewriteVitestImports(), the import is: import { page, server, ... } from '../browser/index.js';\n    // We need:\n    //   - page from '../browser/context.js' (browser-safe)\n    //   - server removed (we'll use window.__vitest_worker__.config instead)\n    //   - other imports (utils, userEvent) still from '../browser/index.js'\n\n    if (provider.extraImports.length === 0) {\n      // playwright: just import page from context.js\n      const serverImportPattern =\n        /import \\{ page, server \\} from ['\"]\\.\\.\\/browser\\/index\\.js['\"];?/;\n      if (serverImportPattern.test(content)) {\n        content = content.replace(\n          serverImportPattern,\n          `import { page } from '../browser/context.js';`,\n        );\n        console.log(`  [${provider.name}] Changed server import to browser-safe context import`);\n        patched = true;\n      }\n    } else {\n      // webdriverio/preview: import page from context.js, keep other imports from index.js\n      const extraImportsStr = provider.extraImports.join(', ');\n      const importPattern = new RegExp(\n        `import \\\\{ page, server, ${extraImportsStr} \\\\} from ['\"]\\\\.\\\\./browser/index\\\\.js['\"];?`,\n      );\n      if (importPattern.test(content)) {\n        const replacement = `import { page } from '../browser/context.js';\\nimport { ${extraImportsStr} } from '../browser/index.js';`;\n        content = content.replace(importPattern, replacement);\n        console.log(\n          `  [${provider.name}] Split imports: page from context.js, {${extraImportsStr}} from index.js`,\n        );\n        patched = true;\n      }\n    }\n\n    if (!patched) {\n      console.log(`  Warning: [${provider.name}] Could not find server import to patch`);\n    }\n\n    // 2. Replace all server.config references with browser-accessible window.__vitest_worker__.config\n    // This handles both:\n    //   - server.config.browser.locators.testIdAttribute\n    //   - server.config.browser.ui\n    const serverConfigPattern = /server\\.config\\./g;\n    const matchCount = (content.match(serverConfigPattern) || []).length;\n    if (matchCount > 0) {\n      content = content.replace(serverConfigPattern, `window.__vitest_worker__.config.`);\n      console.log(\n        `  [${provider.name}] Replaced ${matchCount} server.config references with window.__vitest_worker__.config`,\n      );\n    }\n\n    await writeFile(locatorsPath, content, 'utf-8');\n    console.log(`  Successfully patched @vitest/${provider.name}/locators.js`);\n  }\n}\n\n/**\n * Create browser-compat.js shim that re-exports @vitest/browser compatible symbols.\n * This allows our package to be used as an override for @vitest/browser.\n */\nasync function createBrowserCompatShim() {\n  console.log('\\nCreating browser-compat shim...');\n\n  const browserIndexPath = join(distDir, '@vitest/browser/index.js');\n\n  try {\n    await stat(browserIndexPath);\n  } catch {\n    console.log('  Warning: @vitest/browser/index.js not found, skipping');\n    return;\n  }\n\n  const browserSymbols = [\n    'resolveScreenshotPath',\n    'defineBrowserProvider',\n    'parseKeyDef',\n    'defineBrowserCommand',\n  ];\n\n  const shimContent = `// Re-export @vitest/browser compatible symbols\n// This allows this package to be used as an override for @vitest/browser\nexport { ${browserSymbols.join(', ')} } from './@vitest/browser/index.js';\n`;\n\n  const shimPath = join(distDir, 'browser-compat.js');\n  await writeFile(shimPath, shimContent, 'utf-8');\n  console.log(`  Created ${relative(projectDir, shimPath)}`);\n}\n\n/**\n * Create a browser-safe stub for module-runner.\n * The real module-runner contains Node.js-only code (process.platform, Buffer, etc.)\n * that causes browsers to hang when loaded. This stub provides empty/placeholder\n * exports so that browser code can import without errors.\n */\nasync function createModuleRunnerStub() {\n  console.log('\\nCreating browser-safe module-runner stub...');\n\n  const stubContent = `// Browser-safe stub for module-runner\n// The real module-runner contains Node.js-only code that crashes browsers\n// This stub provides placeholder exports for browser compatibility\n\n// Stub class - browser doesn't actually use these\nexport class EvaluatedModules {\n  constructor() {\n    this.idToModuleMap = new Map();\n    this.fileToModulesMap = new Map();\n    this.urlToIdModuleMap = new Map();\n  }\n  getModuleById() { return undefined; }\n  getModulesByFile() { return []; }\n  getModuleByUrl() { return undefined; }\n  ensureModule() { return {}; }\n  invalidateModule() {}\n  clear() {}\n}\n\nexport class ModuleRunner {\n  constructor() {}\n  async import() { throw new Error('ModuleRunner is not available in browser'); }\n  evaluatedModules = new EvaluatedModules();\n}\n\nexport class ESModulesEvaluator {\n  constructor() {}\n  async runExternalModule() { return {}; }\n  async runViteModule() { return {}; }\n}\n\n// Stub functions\nexport function createDefaultImportMeta() { return {}; }\nexport function createNodeImportMeta() { return {}; }\nexport function createWebSocketModuleRunnerTransport() { return {}; }\nexport function normalizeModuleId(id) { return id; }\n\n// SSR-related constants (browser doesn't use these)\nexport const ssrDynamicImportKey = '__vite_ssr_dynamic_import__';\nexport const ssrExportAllKey = '__vite_ssr_exportAll__';\nexport const ssrExportNameKey = '__vite_ssr_export__';\nexport const ssrImportKey = '__vite_ssr_import__';\nexport const ssrImportMetaKey = '__vite_ssr_import_meta__';\nexport const ssrModuleExportsKey = '__vite_ssr_exports__';\n`;\n\n  const stubPath = join(distDir, 'module-runner-stub.js');\n  await writeFile(stubPath, stubContent, 'utf-8');\n  console.log(`  Created ${relative(projectDir, stubPath)}`);\n}\n\n/**\n * Create a Node.js-specific entry that includes @vitest/browser symbols.\n * Browser code will use index.js (no browser-provider imports) to avoid loading Node.js code.\n * Node.js code (like @vitest/browser-playwright) will use index-node.js which includes\n * the browser symbols needed for pnpm override compatibility.\n *\n * This separation is critical because @vitest/browser/index.js imports from vitest/node,\n * which contains Node.js-only code (including __vite__injectQuery) that crashes browsers.\n */\nasync function createNodeEntry() {\n  console.log('\\nCreating Node.js-specific entry for @vitest/browser override...');\n\n  const browserIndexPath = join(distDir, '@vitest/browser/index.js');\n\n  try {\n    await stat(browserIndexPath);\n  } catch {\n    console.log('  Warning: @vitest/browser/index.js not found, skipping');\n    return;\n  }\n\n  const browserSymbols = [\n    'resolveScreenshotPath',\n    'defineBrowserProvider',\n    'parseKeyDef',\n    'defineBrowserCommand',\n  ];\n\n  // Create index-node.js that re-exports everything from index.js plus browser symbols\n  const nodeEntry = `// Node.js-specific entry that includes @vitest/browser provider symbols\n// Browser code should use index.js which doesn't pull in Node.js-only code\nexport * from './index.js';\n\n// Re-export @vitest/browser symbols for pnpm override compatibility\n// These are only needed when this package overrides @vitest/browser in Node.js context\nexport { ${browserSymbols.join(', ')} } from './@vitest/browser/index.js';\n`;\n\n  const nodeEntryPath = join(distDir, 'index-node.js');\n  await writeFile(nodeEntryPath, nodeEntry, 'utf-8');\n  console.log(`  Created dist/index-node.js with @vitest/browser exports`);\n}\n\n/**\n * Copy ALL files from @vitest/browser's dist to our dist.\n * The bundled code in dist/vendor/ calculates paths like:\n *   pkgRoot = resolve(import.meta.url, \"../..\") -> package root\n *   distRoot = resolve(pkgRoot, \"dist\") -> dist/\n * Then looks for client/ files at distRoot, so we copy to dist/ not dist/vendor/.\n */\nasync function copyBrowserClientFiles() {\n  console.log('\\nCopying @vitest/browser files to dist...');\n\n  // Find @vitest/browser's dist directory\n  const vitestBrowserDist = resolve(projectDir, 'node_modules/@vitest/browser/dist');\n\n  // Check if it exists\n  try {\n    await stat(vitestBrowserDist);\n  } catch {\n    console.log('  Warning: @vitest/browser not installed, skipping');\n    return;\n  }\n\n  // Copy all files from @vitest/browser/dist to our dist/\n  // The bundled code at dist/vendor/ resolves paths relative to dist/\n  // Use recursive directory traversal to include dotfiles (glob doesn't handle them well)\n  let copiedCount = 0;\n\n  // Rewrite imports in copied JS files to use our dist files\n  // The relative path depends on the file's location relative to dist/\n  function rewriteImports(content: string, destPath: string): string {\n    const fileDir = parse(destPath).dir;\n\n    // Calculate relative path from file location to vendor directory\n    const vendorPath = join(distDir, 'vendor');\n    let relativeToVendor = relative(fileDir, vendorPath);\n    // Ensure path starts with ./ for relative imports\n    if (!relativeToVendor.startsWith('.')) {\n      relativeToVendor = './' + relativeToVendor;\n    }\n\n    // Calculate relative path from file location to dist directory\n    let relativeToDist = relative(fileDir, distDir);\n    if (!relativeToDist.startsWith('.')) {\n      relativeToDist = './' + relativeToDist;\n    }\n\n    // Rewrite @vitest/* imports to use our copied @vitest files\n    for (const [pkg, distPath] of Object.entries(VITEST_PACKAGE_TO_PATH)) {\n      if (!pkg.startsWith('@vitest/')) {\n        continue;\n      }\n      // Pattern: from\"@vitest/runner\" or from \"@vitest/runner\"\n      const importPattern = new RegExp(`from\\\\s*[\"']${pkg.replace('/', '\\\\/')}[\"']`, 'g');\n      content = content.replace(importPattern, `from\"${relativeToDist}/${distPath}\"`);\n    }\n\n    // Rewrite vitest/* subpath imports to use our dist files\n    // These are the actual entry points for vitest's browser-safe exports\n    const vitestSubpathRewrites: Record<string, string> = {\n      'vitest/browser': `${relativeToDist}/context.js`, // vitest/browser exports context API\n      'vitest/internal/browser': `${relativeToDist}/browser.js`,\n      'vitest/runners': `${relativeToDist}/runners.js`,\n    };\n    for (const [specifier, destFile] of Object.entries(vitestSubpathRewrites)) {\n      const importPattern = new RegExp(`from\\\\s*[\"']${specifier.replace('/', '\\\\/')}[\"']`, 'g');\n      content = content.replace(importPattern, `from\"${destFile}\"`);\n    }\n\n    // Special handling for @vitest/browser/client -> our client.js\n    // This is needed because the browser client files import from @vitest/browser/client\n    const browserClientPattern = /from\\s*[\"']@vitest\\/browser\\/client[\"']/g;\n    content = content.replace(browserClientPattern, `from\"${relativeToDist}/client.js\"`);\n\n    // Handle imports from ./index.js which is Node.js-only code\n    // In browser context, 'server' should read from __vitest_browser_runner__ at runtime\n    // Replace: import{server}from'./index.js' with a browser-safe stub\n    const serverStub = `const server = {\n  get browser() { return window.__vitest_browser_runner__?.config?.browser?.name; },\n  get config() { return window.__vitest_browser_runner__?.config || {}; },\n  get commands() { return window.__vitest_browser_runner__?.commands || {}; },\n  get provider() { return window.__vitest_browser_runner__?.provider; },\n};`;\n    content = content.replace(\n      /import\\s*\\{\\s*server\\s*\\}\\s*from\\s*['\"]\\.\\/index\\.js['\"];?/g,\n      serverStub,\n    );\n\n    // Remove side-effect imports from ./index.js (Node.js-only)\n    // Pattern: import'./index.js'; at the end of an import statement\n    content = content.replace(/import\\s*['\"]\\.\\/index\\.js['\"];?/g, '');\n\n    return content;\n  }\n\n  async function copyDirRecursive(srcDir: string, destDir: string) {\n    const entries = await readdir(srcDir, { withFileTypes: true });\n\n    for (const entry of entries) {\n      const srcPath = join(srcDir, entry.name);\n      const destPath = join(destDir, entry.name);\n\n      if (entry.isDirectory()) {\n        await mkdir(destPath, { recursive: true });\n        await copyDirRecursive(srcPath, destPath);\n      } else if (entry.isFile()) {\n        // Skip if file already exists (our bundled code takes precedence)\n        try {\n          await stat(destPath);\n          continue;\n        } catch {\n          // File doesn't exist, copy it\n        }\n        await mkdir(parse(destPath).dir, { recursive: true });\n\n        // For JS files, rewrite imports; otherwise just copy\n        if (entry.name.endsWith('.js')) {\n          let content = await readFile(srcPath, 'utf-8');\n          content = rewriteImports(content, destPath);\n          await writeFile(destPath, content, 'utf-8');\n        } else {\n          await copyFile(srcPath, destPath);\n        }\n        copiedCount++;\n      }\n    }\n  }\n\n  await copyDirRecursive(vitestBrowserDist, distDir);\n\n  // Create dummy.js for placeholder exports (matchers, utils)\n  const dummyContent = '// Placeholder for browser compatibility\\nexport {};\\n';\n  await writeFile(join(distDir, 'dummy.js'), dummyContent, 'utf-8');\n\n  console.log(`  Copied ${copiedCount} files from @vitest/browser to dist`);\n\n  // Create vendor stubs for browser packages that aren't bundled\n  // Other dist files reference these vendor paths but we don't bundle browser packages\n  // to avoid Node.js code leakage. Instead, we create stubs that re-export from actual dist files.\n  console.log('  Creating vendor stubs for browser packages...');\n  const browserVendorStubs = [\n    {\n      vendorFile: 'vitest_browser.mjs',\n      // vitest/browser exports the context API (page, server, userEvent)\n      content: `// Stub for browser context - re-exports from our context.js\nexport * from '../context.js';\n`,\n    },\n    {\n      vendorFile: 'vitest_internal_browser.mjs',\n      // vitest/internal/browser is browser.js\n      content: `// Stub for internal browser API - re-exports from our browser.js\nexport * from '../browser.js';\n`,\n    },\n    {\n      vendorFile: 'vitest_runners.mjs',\n      // vitest/runners\n      content: `// Stub for runners - re-exports from our runners.js\nexport * from '../runners.js';\n`,\n    },\n    {\n      vendorFile: 'vitest_runner.mjs',\n      // @vitest/runner (note: singular, not plural like vitest_runners which is vitest/runners)\n      content: `// Stub for @vitest/runner - re-exports from our copied @vitest/runner\nexport * from '../@vitest/runner/index.js';\n`,\n    },\n  ];\n\n  for (const { vendorFile, content } of browserVendorStubs) {\n    const stubPath = join(distDir, 'vendor', vendorFile);\n    await writeFile(stubPath, content, 'utf-8');\n  }\n  console.log(`  Created ${browserVendorStubs.length} vendor stubs`);\n}\n\n/**\n * Create browser/ directory at package root with context files.\n * The package exports \"./browser\" pointing to these files:\n *   - browser/context.js: Runtime guard (throws if used outside browser mode)\n *   - browser/context.d.ts: Re-exports types from dist/@vitest/browser/context.d.ts\n *\n * These files are NOT tracked in git (.gitignore excludes browser/)\n * but ARE included in the package (package.json files: [\"browser/**\"])\n */\nasync function createBrowserEntryFiles() {\n  console.log('\\nCreating browser/ entry files...');\n\n  const browserDir = resolve(projectDir, 'browser');\n  await mkdir(browserDir, { recursive: true });\n\n  // 1. Copy context.js from @vitest/browser (runtime guard)\n  const srcContextJs = resolve(projectDir, 'node_modules/@vitest/browser/context.js');\n  const destContextJs = join(browserDir, 'context.js');\n  await copyFile(srcContextJs, destContextJs);\n  console.log('  Created browser/context.js');\n\n  // 2. Create context.d.ts that re-exports from our bundled types\n  const contextDtsContent = `// Re-export browser context types from bundled @vitest/browser package\n// This provides: page, userEvent, server, commands, utils, locators, cdp, Locator, etc.\n// The bundled context.d.ts has imports rewritten to point to our dist files\nexport * from '../dist/@vitest/browser/context.d.ts'\n`;\n  const destContextDts = join(browserDir, 'context.d.ts');\n  await writeFile(destContextDts, contextDtsContent, 'utf-8');\n  console.log('  Created browser/context.d.ts');\n}\n\n/**\n * Patch module augmentations in global.d.*.d.ts files.\n *\n * The original vitest types use module augmentation like:\n *   declare module \"@vitest/expect\" { interface Assertion<T> { toMatchSnapshot: ... } }\n *\n * Since we bundle @vitest/* packages inside dist/@vitest/*, the bare specifier\n * \"@vitest/expect\" doesn't exist as a package for consumers. This breaks the\n * module augmentation - TypeScript can't find @vitest/expect to augment.\n *\n * The fix has two parts:\n * 1. Change module augmentation to use relative paths that TypeScript CAN resolve:\n *      declare module \"../@vitest/expect/index.js\" { ... }\n * 2. Merge augmented interface/type definitions into the target .d.ts files so that\n *    downstream DTS bundlers (rolldown) can resolve them without cross-file augmentation.\n */\nasync function patchModuleAugmentations() {\n  console.log('\\nPatching module augmentations in global.d.*.d.ts files...');\n\n  const chunksDir = join(distDir, 'chunks');\n  const globalDtsFiles: string[] = [];\n\n  // Find all global.d.*.d.ts files\n  for await (const file of fsGlob(join(chunksDir, 'global.d.*.d.ts'))) {\n    globalDtsFiles.push(file);\n  }\n\n  if (globalDtsFiles.length === 0) {\n    console.log('  No global.d.*.d.ts files found');\n    return;\n  }\n\n  // Module augmentation mappings: bare specifier -> [relative path, target .d.ts file]\n  const augmentationMappings: Record<string, { relativePath: string; targetFile: string }> = {\n    '@vitest/expect': {\n      relativePath: '../@vitest/expect/index.js',\n      targetFile: join(distDir, '@vitest/expect/index.d.ts'),\n    },\n    '@vitest/runner': {\n      relativePath: '../@vitest/runner/index.js',\n      targetFile: join(distDir, '@vitest/runner/utils.d.ts'),\n    },\n  };\n\n  for (const file of globalDtsFiles) {\n    let content = await readFile(file, 'utf-8');\n    let modified = false;\n\n    for (const [bareSpecifier, { relativePath, targetFile }] of Object.entries(\n      augmentationMappings,\n    )) {\n      const oldPattern = `declare module \"${bareSpecifier}\"`;\n\n      // Extract the augmentation block content using brace matching\n      const startIdx = content.indexOf(oldPattern);\n      const braceStart = startIdx !== -1 ? content.indexOf('{', startIdx) : -1;\n      if (braceStart === -1) {\n        continue;\n      }\n\n      let depth = 0;\n      let braceEnd = -1;\n      for (let i = braceStart; i < content.length; i++) {\n        if (content[i] === '{') {\n          depth++;\n        } else if (content[i] === '}') {\n          depth--;\n          if (depth === 0) {\n            braceEnd = i;\n            break;\n          }\n        }\n      }\n      if (braceEnd === -1) {\n        continue;\n      }\n\n      const innerContent = content.slice(braceStart + 1, braceEnd).trim();\n\n      // Merge only NEW type declarations into the target .d.ts file.\n      // Interfaces that already exist (e.g., ExpectStatic, Assertion, MatcherState) must NOT\n      // be re-declared, as that would shadow extends clauses and break call signatures.\n      if (innerContent && existsSync(targetFile)) {\n        let targetContent = await readFile(targetFile, 'utf-8');\n\n        // Extract individual interface blocks from the augmentation content\n        const interfaceRegex = /(?:export\\s+)?interface\\s+(\\w+)(?:<[^>]*>)?\\s*\\{/g;\n        let match;\n        const newDeclarations: string[] = [];\n\n        while ((match = interfaceRegex.exec(innerContent)) !== null) {\n          const name = match[1];\n          // Only merge if this interface does NOT already exist in the target file.\n          // Check both direct declarations (interface Name) and re-exports (export type { Name }).\n          const hasDirectDecl = new RegExp(`\\\\binterface\\\\s+${name}\\\\b`).test(targetContent);\n          const exportTypeMatch = targetContent.match(/export\\s+type\\s*\\{([^}]*)\\}/);\n          const isReExported =\n            exportTypeMatch != null && new RegExp(`\\\\b${name}\\\\b`).test(exportTypeMatch[1]);\n          if (hasDirectDecl || isReExported) {\n            console.log(\n              `  Skipped existing interface \"${name}\" (already in ${basename(targetFile)})`,\n            );\n            continue;\n          }\n\n          // Extract this interface block using brace matching\n          const ifaceStart = match.index;\n          const ifaceBraceStart = innerContent.indexOf('{', ifaceStart);\n          let ifaceDepth = 0;\n          let ifaceBraceEnd = -1;\n          for (let i = ifaceBraceStart; i < innerContent.length; i++) {\n            if (innerContent[i] === '{') {\n              ifaceDepth++;\n            } else if (innerContent[i] === '}') {\n              ifaceDepth--;\n              if (ifaceDepth === 0) {\n                ifaceBraceEnd = i;\n                break;\n              }\n            }\n          }\n          if (ifaceBraceEnd === -1) {\n            continue;\n          }\n\n          let block = innerContent.slice(ifaceStart, ifaceBraceEnd + 1).trim();\n          if (!block.startsWith('export')) {\n            block = `export ${block}`;\n          }\n          newDeclarations.push(block);\n          console.log(`  Merged new interface \"${name}\" into ${basename(targetFile)}`);\n        }\n\n        if (newDeclarations.length > 0) {\n          targetContent += `\\n// Merged from module augmentation: declare module \"${bareSpecifier}\"\\n${newDeclarations.join('\\n')}\\n`;\n          await writeFile(targetFile, targetContent, 'utf-8');\n        }\n      }\n\n      // Rewrite declare module path to relative\n      const newPattern = `declare module \"${relativePath}\"`;\n      content = content.replaceAll(oldPattern, newPattern);\n      modified = true;\n      console.log(`  Patched: ${bareSpecifier} -> ${relativePath} in ${basename(file)}`);\n    }\n\n    if (modified) {\n      await writeFile(file, content, 'utf-8');\n    }\n  }\n\n  // Re-export BrowserCommands from context.d.ts (imported but not exported)\n  const contextDtsPath = join(distDir, '@vitest/browser/context.d.ts');\n  if (existsSync(contextDtsPath)) {\n    let content = await readFile(contextDtsPath, 'utf-8');\n    if (\n      content.includes('BrowserCommands') &&\n      !content.match(/export\\s+(type\\s+)?\\{[^}]*BrowserCommands/)\n    ) {\n      content += '\\nexport type { BrowserCommands };\\n';\n      await writeFile(contextDtsPath, content, 'utf-8');\n      console.log('  Added BrowserCommands re-export to context.d.ts');\n    }\n  }\n\n  // Validate: ensure no duplicate top-level interface declarations were introduced by merging.\n  // Only count interfaces at the module scope (not nested inside declare global, namespace, etc.)\n  for (const [bareSpecifier, { targetFile }] of Object.entries(augmentationMappings)) {\n    if (!existsSync(targetFile)) {\n      continue;\n    }\n    const finalContent = await readFile(targetFile, 'utf-8');\n\n    // Extract top-level interface names by tracking brace depth\n    const topLevelInterfaces: string[] = [];\n    let depth = 0;\n    for (let i = 0; i < finalContent.length; i++) {\n      if (finalContent[i] === '{') {\n        depth++;\n      } else if (finalContent[i] === '}') {\n        depth--;\n      } else if (depth === 0) {\n        const remaining = finalContent.slice(i);\n        const m = remaining.match(/^interface\\s+(\\w+)/);\n        if (m) {\n          topLevelInterfaces.push(m[1]);\n          i += m[0].length - 1;\n        }\n      }\n    }\n\n    const counts = new Map<string, number>();\n    for (const name of topLevelInterfaces) {\n      counts.set(name, (counts.get(name) || 0) + 1);\n    }\n\n    for (const [name, count] of counts) {\n      if (count > 1) {\n        throw new Error(\n          `Interface \"${name}\" is declared ${count} times at top level in ${basename(targetFile)}. ` +\n            `Module augmentation merge for \"${bareSpecifier}\" likely created a duplicate ` +\n            `declaration that will shadow extends clauses and break type signatures.`,\n        );\n      }\n    }\n  }\n}\n\n/**\n * Add triple-slash reference to @types/chai in @vitest/expect types.\n *\n * The @vitest/expect types use the Chai namespace (e.g., Chai.Assertion) which\n * is defined in @types/chai. Without a reference directive, TypeScript won't\n * automatically find the Chai types, causing the `not` property and other\n * chai-specific features to be missing from the Assertion interface.\n */\nasync function patchChaiTypeReference() {\n  console.log('\\nAdding @types/chai reference to @vitest/expect types...');\n\n  const expectIndexDts = join(distDir, '@vitest/expect/index.d.ts');\n\n  let content = await readFile(expectIndexDts, 'utf-8');\n\n  // Check if reference already exists\n  if (content.includes('/// <reference types=\"chai\"')) {\n    console.log('  Reference already exists, skipping');\n    return;\n  }\n\n  // Add triple-slash reference at the top\n  content = `/// <reference types=\"chai\" />\\n${content}`;\n\n  await writeFile(expectIndexDts, content, 'utf-8');\n  console.log('  Added /// <reference types=\"chai\" /> to @vitest/expect/index.d.ts');\n}\n\n/**\n * Patch the vitest mocker to recognize @voidzero-dev packages as valid sources for vi/vitest.\n *\n * The mocker's hoistMocks function checks if `vi` is imported from the 'vitest' module.\n * When users import from 'vite-plus/test' instead, the mocker doesn't\n * recognize it and throws \"There are some problems in resolving the mocks API\".\n *\n * This patch modifies the equality check to also accept our package names:\n * - vite-plus/test\n * - @voidzero-dev/vite-plus-test\n */\nasync function patchMockerHoistedModule() {\n  console.log('\\nPatching vitest mocker to recognize @voidzero-dev packages...');\n\n  // The hoistedModule check may be in node.js or chunk-hoistMocks.js depending on the vitest version\n  const candidateFiles = [\n    join(distDir, '@vitest/mocker/node.js'),\n    join(distDir, '@vitest/mocker/chunk-hoistMocks.js'),\n  ];\n\n  // Find and replace the hoistedModule check\n  // Original: if (hoistedModule === source) {\n  // New: if (hoistedModule === source || source === \"vite-plus/test\" || source === \"@voidzero-dev/vite-plus-test\") {\n  const originalCheck = 'if (hoistedModule === source) {';\n  const newCheck =\n    'if (hoistedModule === source || source === \"vite-plus/test\" || source === \"@voidzero-dev/vite-plus-test\") {';\n\n  let patched = false;\n  for (const candidatePath of candidateFiles) {\n    let content: string;\n    try {\n      content = await readFile(candidatePath, 'utf-8');\n    } catch {\n      continue;\n    }\n    if (content.includes(originalCheck)) {\n      content = content.replace(originalCheck, newCheck);\n      await writeFile(candidatePath, content, 'utf-8');\n      console.log(`  Patched hoistMocks to recognize @voidzero-dev packages in ${candidatePath}`);\n      patched = true;\n      break;\n    }\n  }\n\n  if (!patched) {\n    throw new Error(\n      'Could not find hoistedModule check to patch in @vitest/mocker. ' +\n        'This likely means vitest code has changed and the patch needs to be updated.',\n    );\n  }\n}\n\n/**\n * Patch vitest's ModuleRunnerTransform plugin to automatically add known\n * packages that use `expect.extend()` internally to `server.deps.inline`.\n *\n * When third-party libraries (e.g., @testing-library/jest-dom) call\n * `require('vitest').expect.extend(matchers)`, the npm override causes\n * a separate module instance to be created, so matchers are registered\n * on a different `chai` instance than the one used by the test runner.\n *\n * By inlining these packages via `server.deps.inline`, the Vite module\n * runner processes them through its transform pipeline, ensuring they\n * share the same module instance as the test runner.\n *\n * See: https://github.com/voidzero-dev/vite-plus/issues/897\n */\nasync function patchServerDepsInline() {\n  console.log('\\nPatching server.deps.inline for expect.extend compatibility...');\n\n  let cliApiChunk: string | undefined;\n  for await (const chunk of fsGlob(join(distDir, 'chunks/cli-api.*.js'))) {\n    cliApiChunk = chunk;\n    break;\n  }\n\n  if (!cliApiChunk) {\n    throw new Error('cli-api chunk not found for patchServerDepsInline');\n  }\n\n  let content = await readFile(cliApiChunk, 'utf-8');\n\n  // Packages that internally call expect.extend() and break under npm override.\n  // These must be inlined so they share the same vitest module instance.\n  const inlinePackages = ['@testing-library/jest-dom', '@storybook/test', 'jest-extended'];\n\n  // Find the configResolved handler in ModuleRunnerTransform (vitest:environments-module-runner)\n  // and inject our inline packages after the existing server.deps.inline logic.\n  const original = `if (external.length) {\n          testConfig.server.deps.external ??= [];\n          testConfig.server.deps.external.push(...external);\n        }`;\n\n  const patched = `if (external.length) {\n          testConfig.server.deps.external ??= [];\n          testConfig.server.deps.external.push(...external);\n        }\n        // Auto-inline packages that use expect.extend() internally (#897)\n        // Only inline packages that are actually installed in the project.\n        if (testConfig.server.deps.inline !== true) {\n          testConfig.server.deps.inline ??= [];\n          if (Array.isArray(testConfig.server.deps.inline)) {\n            const _require = createRequire(config.root + \"/package.json\");\n            const autoInline = ${JSON.stringify(inlinePackages)};\n            for (const pkg of autoInline) {\n              if (testConfig.server.deps.inline.includes(pkg)) continue;\n              try {\n                _require.resolve(pkg);\n                testConfig.server.deps.inline.push(pkg);\n              } catch {\n                // Package not installed in the project — skip silently\n              }\n            }\n          }\n        }`;\n\n  if (!content.includes(original)) {\n    throw new Error(\n      'Could not find server.deps.external pattern in ' +\n        cliApiChunk +\n        '. This likely means vitest code has changed and the patch needs to be updated.',\n    );\n  }\n\n  content = content.replace(original, patched);\n  await writeFile(cliApiChunk, content, 'utf-8');\n  console.log(`  Added auto-inline for: ${inlinePackages.join(', ')}`);\n}\n\n/**\n * Create /plugins/* exports for all copied @vitest/* packages.\n * This allows pnpm overrides to redirect @vitest/* imports to our copied versions.\n * e.g., @vitest/runner -> vitest/plugins/runner\n *       @vitest/utils/error -> vitest/plugins/utils-error\n */\nasync function createPluginExports() {\n  console.log('\\nCreating plugin exports for @vitest/* packages...');\n\n  const pluginsDir = join(distDir, 'plugins');\n  // Clean up stale plugin files from previous builds\n  await rm(pluginsDir, { recursive: true, force: true });\n  await mkdir(pluginsDir, { recursive: true });\n\n  const createdExports: Array<{ exportPath: string; shimFile: string }> = [];\n\n  for (const [pkg, distPath] of Object.entries(VITEST_PACKAGE_TO_PATH)) {\n    // Only create exports for @vitest/* packages\n    if (!pkg.startsWith('@vitest/')) {\n      continue;\n    }\n    // Convert @vitest/runner -> runner, @vitest/utils/error -> utils-error\n    // @vitest/utils/source-map/node -> utils-source-map-node\n    const exportName = pkg.replace('@vitest/', '').replaceAll('/', '-');\n    const shimFileName = `${exportName}.mjs`;\n    const shimPath = join(pluginsDir, shimFileName);\n\n    // Create the shim file that re-exports everything from @vitest/\n    const shimContent = `// Re-export ${pkg} from copied @vitest package\nexport * from '../${distPath}';\n`;\n\n    await writeFile(shimPath, shimContent, 'utf-8');\n    createdExports.push({\n      exportPath: `./plugins/${exportName}`,\n      shimFile: `./dist/plugins/${shimFileName}`,\n    });\n    console.log(`  Created plugins/${shimFileName} -> ${distPath}`);\n  }\n\n  return createdExports;\n}\n\n/**\n * Validate that all external dependencies in dist are listed in package.json\n */\nasync function validateExternalDeps() {\n  console.log('\\nValidating external dependencies...');\n\n  // Collect all declared dependencies\n  const declaredDeps = new Set<string>([\n    ...Object.keys(pkg.dependencies || {}),\n    ...Object.keys(pkg.peerDependencies || {}),\n  ]);\n\n  // Also include self-references\n  declaredDeps.add(pkg.name);\n  declaredDeps.add('vitest'); // Self-reference via vitest name\n\n  // Collect all external specifiers from ALL dist files (including vendor)\n  const externalSpecifiers = new Map<string, Set<string>>(); // specifier -> files\n\n  const allJsFiles = fsGlob(join(distDir, '**/*.{js,mjs,cjs}'));\n\n  for await (const file of allJsFiles) {\n    const content = await readFile(file, 'utf-8');\n    const isCjs = file.endsWith('.cjs');\n\n    // Parse with oxc-parser\n    const result = parseSync(file, content, {\n      sourceType: isCjs ? 'script' : 'module',\n    });\n\n    const specifiers = new Set<string>();\n\n    // Collect ESM static imports\n    for (const imp of result.module.staticImports) {\n      specifiers.add(imp.moduleRequest.value);\n    }\n\n    // Collect ESM static exports (re-exports)\n    for (const exp of result.module.staticExports) {\n      for (const entry of exp.entries) {\n        if (entry.moduleRequest) {\n          specifiers.add(entry.moduleRequest.value);\n        }\n      }\n    }\n\n    // Collect dynamic imports (only string literals)\n    for (const dynImp of result.module.dynamicImports) {\n      const rawText = content.slice(dynImp.moduleRequest.start, dynImp.moduleRequest.end);\n      if (\n        (rawText.startsWith(\"'\") && rawText.endsWith(\"'\")) ||\n        (rawText.startsWith('\"') && rawText.endsWith('\"'))\n      ) {\n        specifiers.add(rawText.slice(1, -1));\n      }\n    }\n\n    // For CJS files, also scan for require() calls\n    if (isCjs) {\n      const requireRegex = /require\\s*\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)/g;\n      let match;\n      while ((match = requireRegex.exec(content)) !== null) {\n        specifiers.add(match[1]);\n      }\n    }\n\n    // Filter and record external specifiers\n    for (const specifier of specifiers) {\n      // Skip relative paths\n      if (specifier.startsWith('.') || specifier.startsWith('/')) {\n        continue;\n      }\n      // Skip node built-ins\n      if (NODE_BUILTINS.has(specifier)) {\n        continue;\n      }\n      // Skip Node.js subpath imports\n      if (specifier.startsWith('#')) {\n        continue;\n      }\n\n      // Get the package name (handle scoped packages and subpaths)\n      const packageName = getPackageName(specifier);\n      if (!packageName) {\n        continue;\n      }\n\n      // Check if it's declared\n      if (declaredDeps.has(packageName)) {\n        continue;\n      }\n      // Check if it's in the blocklist (intentionally external)\n      if (EXTERNAL_BLOCKLIST.has(packageName) || EXTERNAL_BLOCKLIST.has(specifier)) {\n        continue;\n      }\n\n      // Record undeclared external\n      if (!externalSpecifiers.has(specifier)) {\n        externalSpecifiers.set(specifier, new Set());\n      }\n      externalSpecifiers.get(specifier)!.add(relative(distDir, file));\n    }\n  }\n\n  if (externalSpecifiers.size === 0) {\n    console.log('  ✓ All external dependencies are declared in package.json');\n    return;\n  }\n\n  // Group by package name\n  const byPackage = new Map<string, Set<string>>();\n  for (const [specifier, _files] of externalSpecifiers) {\n    const packageName = getPackageName(specifier)!;\n    if (!byPackage.has(packageName)) {\n      byPackage.set(packageName, new Set());\n    }\n    byPackage.get(packageName)!.add(specifier);\n  }\n\n  console.log(`\\n  ⚠ Found ${byPackage.size} undeclared external dependencies:\\n`);\n  for (const [packageName, specifiers] of byPackage.entries()) {\n    const files = externalSpecifiers.get([...specifiers][0])!;\n    console.log(`    ${packageName}`);\n    for (const specifier of specifiers) {\n      if (specifier !== packageName) {\n        console.log(`      - ${specifier}`);\n      }\n    }\n    console.log(\n      `      (used in: ${[...files].slice(0, 3).join(', ')}${files.size > 3 ? '...' : ''})`,\n    );\n  }\n}\n\n/**\n * Extract the package name from a specifier (handles scoped packages and subpaths)\n */\nfunction getPackageName(specifier: string): string | null {\n  // Scoped package: @scope/name or @scope/name/subpath\n  if (specifier.startsWith('@')) {\n    const parts = specifier.split('/');\n    if (parts.length >= 2) {\n      return `${parts[0]}/${parts[1]}`;\n    }\n    return null;\n  }\n  // Regular package: name or name/subpath\n  const parts = specifier.split('/');\n  return parts[0] || null;\n}\n"
  },
  {
    "path": "packages/test/package.json",
    "content": "{\n  \"name\": \"@voidzero-dev/vite-plus-test\",\n  \"version\": \"0.0.0\",\n  \"description\": \"The Unified Toolchain for the Web\",\n  \"homepage\": \"https://viteplus.dev/guide\",\n  \"bugs\": {\n    \"url\": \"https://github.com/voidzero-dev/vite-plus/issues\"\n  },\n  \"license\": \"MIT\",\n  \"author\": \"VoidZero Inc.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/voidzero-dev/vite-plus.git\",\n    \"directory\": \"packages/test\"\n  },\n  \"files\": [\n    \"*.cjs\",\n    \"*.cts\",\n    \"*.d.ts\",\n    \"*.mjs\",\n    \"browser/**\",\n    \"dist/**\"\n  ],\n  \"type\": \"module\",\n  \"sideEffects\": false,\n  \"main\": \"./dist/index.js\",\n  \"module\": \"./dist/index.js\",\n  \"types\": \"./dist/index.d.ts\",\n  \"imports\": {\n    \"#module-evaluator\": {\n      \"types\": \"./dist/module-evaluator.d.ts\",\n      \"default\": \"./dist/module-evaluator.js\"\n    },\n    \"#nodejs-worker-loader\": \"./dist/nodejs-worker-loader.js\"\n  },\n  \"exports\": {\n    \".\": {\n      \"import\": {\n        \"types\": \"./dist/index.d.ts\",\n        \"browser\": \"./dist/index.js\",\n        \"node\": \"./dist/index-node.js\",\n        \"default\": \"./dist/index.js\"\n      },\n      \"require\": {\n        \"types\": \"./index.d.cts\",\n        \"default\": \"./index.cjs\"\n      }\n    },\n    \"./browser\": {\n      \"types\": \"./browser/context.d.ts\",\n      \"default\": \"./browser/context.js\"\n    },\n    \"./package.json\": \"./package.json\",\n    \"./optional-types.js\": {\n      \"types\": \"./optional-types.d.ts\"\n    },\n    \"./optional-runtime-types.js\": {\n      \"types\": \"./optional-runtime-types.d.ts\"\n    },\n    \"./src/*\": \"./src/*\",\n    \"./globals\": {\n      \"types\": \"./globals.d.ts\"\n    },\n    \"./jsdom\": {\n      \"types\": \"./jsdom.d.ts\"\n    },\n    \"./importMeta\": {\n      \"types\": \"./importMeta.d.ts\"\n    },\n    \"./import-meta\": {\n      \"types\": \"./import-meta.d.ts\"\n    },\n    \"./node\": {\n      \"types\": \"./dist/node.d.ts\",\n      \"default\": \"./dist/node.js\"\n    },\n    \"./internal/browser\": {\n      \"types\": \"./dist/browser.d.ts\",\n      \"default\": \"./dist/browser.js\"\n    },\n    \"./runners\": {\n      \"types\": \"./dist/runners.d.ts\",\n      \"default\": \"./dist/runners.js\"\n    },\n    \"./suite\": {\n      \"types\": \"./dist/suite.d.ts\",\n      \"default\": \"./dist/suite.js\"\n    },\n    \"./environments\": {\n      \"types\": \"./dist/environments.d.ts\",\n      \"default\": \"./dist/environments.js\"\n    },\n    \"./config\": {\n      \"types\": \"./config.d.ts\",\n      \"require\": \"./dist/config.cjs\",\n      \"default\": \"./dist/config.js\"\n    },\n    \"./coverage\": {\n      \"types\": \"./coverage.d.ts\",\n      \"default\": \"./dist/coverage.js\"\n    },\n    \"./reporters\": {\n      \"types\": \"./dist/reporters.d.ts\",\n      \"default\": \"./dist/reporters.js\"\n    },\n    \"./snapshot\": {\n      \"types\": \"./dist/snapshot.d.ts\",\n      \"default\": \"./dist/snapshot.js\"\n    },\n    \"./runtime\": {\n      \"types\": \"./dist/runtime.d.ts\",\n      \"default\": \"./dist/runtime.js\"\n    },\n    \"./worker\": {\n      \"types\": \"./worker.d.ts\",\n      \"default\": \"./dist/worker.js\"\n    },\n    \"./browser-compat\": {\n      \"default\": \"./dist/browser-compat.js\"\n    },\n    \"./client\": {\n      \"default\": \"./dist/client.js\"\n    },\n    \"./context\": {\n      \"types\": \"./browser/context.d.ts\",\n      \"default\": \"./dist/@vitest/browser/context.js\"\n    },\n    \"./browser/context\": {\n      \"types\": \"./browser/context.d.ts\",\n      \"default\": \"./dist/@vitest/browser/context.js\"\n    },\n    \"./locators\": {\n      \"default\": \"./dist/locators.js\"\n    },\n    \"./matchers\": {\n      \"default\": \"./dist/dummy.js\"\n    },\n    \"./utils\": {\n      \"default\": \"./dist/dummy.js\"\n    },\n    \"./browser-playwright\": {\n      \"types\": \"./dist/@vitest/browser-playwright/index.d.ts\",\n      \"default\": \"./dist/@vitest/browser-playwright/index.js\"\n    },\n    \"./browser-webdriverio\": {\n      \"types\": \"./dist/@vitest/browser-webdriverio/index.d.ts\",\n      \"default\": \"./dist/@vitest/browser-webdriverio/index.js\"\n    },\n    \"./browser-preview\": {\n      \"types\": \"./dist/@vitest/browser-preview/index.d.ts\",\n      \"default\": \"./dist/@vitest/browser-preview/index.js\"\n    },\n    \"./browser/providers/playwright\": {\n      \"types\": \"./dist/@vitest/browser-playwright/index.d.ts\",\n      \"default\": \"./dist/@vitest/browser-playwright/index.js\"\n    },\n    \"./browser/providers/webdriverio\": {\n      \"types\": \"./dist/@vitest/browser-webdriverio/index.d.ts\",\n      \"default\": \"./dist/@vitest/browser-webdriverio/index.js\"\n    },\n    \"./browser/providers/preview\": {\n      \"types\": \"./dist/@vitest/browser-preview/index.d.ts\",\n      \"default\": \"./dist/@vitest/browser-preview/index.js\"\n    },\n    \"./plugins/runner\": {\n      \"default\": \"./dist/plugins/runner.mjs\"\n    },\n    \"./plugins/runner-utils\": {\n      \"default\": \"./dist/plugins/runner-utils.mjs\"\n    },\n    \"./plugins/runner-types\": {\n      \"default\": \"./dist/plugins/runner-types.mjs\"\n    },\n    \"./plugins/utils\": {\n      \"default\": \"./dist/plugins/utils.mjs\"\n    },\n    \"./plugins/utils-source-map\": {\n      \"default\": \"./dist/plugins/utils-source-map.mjs\"\n    },\n    \"./plugins/utils-source-map-node\": {\n      \"default\": \"./dist/plugins/utils-source-map-node.mjs\"\n    },\n    \"./plugins/utils-error\": {\n      \"default\": \"./dist/plugins/utils-error.mjs\"\n    },\n    \"./plugins/utils-helpers\": {\n      \"default\": \"./dist/plugins/utils-helpers.mjs\"\n    },\n    \"./plugins/utils-display\": {\n      \"default\": \"./dist/plugins/utils-display.mjs\"\n    },\n    \"./plugins/utils-timers\": {\n      \"default\": \"./dist/plugins/utils-timers.mjs\"\n    },\n    \"./plugins/utils-highlight\": {\n      \"default\": \"./dist/plugins/utils-highlight.mjs\"\n    },\n    \"./plugins/utils-offset\": {\n      \"default\": \"./dist/plugins/utils-offset.mjs\"\n    },\n    \"./plugins/utils-resolver\": {\n      \"default\": \"./dist/plugins/utils-resolver.mjs\"\n    },\n    \"./plugins/utils-serialize\": {\n      \"default\": \"./dist/plugins/utils-serialize.mjs\"\n    },\n    \"./plugins/utils-constants\": {\n      \"default\": \"./dist/plugins/utils-constants.mjs\"\n    },\n    \"./plugins/utils-diff\": {\n      \"default\": \"./dist/plugins/utils-diff.mjs\"\n    },\n    \"./plugins/spy\": {\n      \"default\": \"./dist/plugins/spy.mjs\"\n    },\n    \"./plugins/expect\": {\n      \"default\": \"./dist/plugins/expect.mjs\"\n    },\n    \"./plugins/snapshot\": {\n      \"default\": \"./dist/plugins/snapshot.mjs\"\n    },\n    \"./plugins/snapshot-environment\": {\n      \"default\": \"./dist/plugins/snapshot-environment.mjs\"\n    },\n    \"./plugins/snapshot-manager\": {\n      \"default\": \"./dist/plugins/snapshot-manager.mjs\"\n    },\n    \"./plugins/mocker\": {\n      \"default\": \"./dist/plugins/mocker.mjs\"\n    },\n    \"./plugins/mocker-node\": {\n      \"default\": \"./dist/plugins/mocker-node.mjs\"\n    },\n    \"./plugins/mocker-browser\": {\n      \"default\": \"./dist/plugins/mocker-browser.mjs\"\n    },\n    \"./plugins/mocker-redirect\": {\n      \"default\": \"./dist/plugins/mocker-redirect.mjs\"\n    },\n    \"./plugins/mocker-transforms\": {\n      \"default\": \"./dist/plugins/mocker-transforms.mjs\"\n    },\n    \"./plugins/mocker-automock\": {\n      \"default\": \"./dist/plugins/mocker-automock.mjs\"\n    },\n    \"./plugins/mocker-register\": {\n      \"default\": \"./dist/plugins/mocker-register.mjs\"\n    },\n    \"./plugins/pretty-format\": {\n      \"default\": \"./dist/plugins/pretty-format.mjs\"\n    },\n    \"./plugins/browser\": {\n      \"default\": \"./dist/plugins/browser.mjs\"\n    },\n    \"./plugins/browser-context\": {\n      \"default\": \"./dist/plugins/browser-context.mjs\"\n    },\n    \"./plugins/browser-client\": {\n      \"default\": \"./dist/plugins/browser-client.mjs\"\n    },\n    \"./plugins/browser-locators\": {\n      \"default\": \"./dist/plugins/browser-locators.mjs\"\n    },\n    \"./plugins/browser-playwright\": {\n      \"default\": \"./dist/plugins/browser-playwright.mjs\"\n    },\n    \"./plugins/browser-webdriverio\": {\n      \"default\": \"./dist/plugins/browser-webdriverio.mjs\"\n    },\n    \"./plugins/browser-preview\": {\n      \"default\": \"./dist/plugins/browser-preview.mjs\"\n    }\n  },\n  \"scripts\": {\n    \"build\": \"oxnode -C dev ./build.ts\"\n  },\n  \"dependencies\": {\n    \"@standard-schema/spec\": \"^1.1.0\",\n    \"@types/chai\": \"^5.2.2\",\n    \"@voidzero-dev/vite-plus-core\": \"workspace:*\",\n    \"es-module-lexer\": \"^1.7.0\",\n    \"obug\": \"^2.1.1\",\n    \"pixelmatch\": \"^7.1.0\",\n    \"pngjs\": \"^7.0.0\",\n    \"sirv\": \"^3.0.2\",\n    \"std-env\": \"^4.0.0\",\n    \"tinybench\": \"^2.9.0\",\n    \"tinyexec\": \"^1.0.2\",\n    \"tinyglobby\": \"^0.2.15\",\n    \"ws\": \"^8.18.3\"\n  },\n  \"devDependencies\": {\n    \"@blazediff/core\": \"1.9.1\",\n    \"@oxc-node/cli\": \"catalog:\",\n    \"@oxc-node/core\": \"catalog:\",\n    \"@vitest/browser\": \"4.1.1\",\n    \"@vitest/browser-playwright\": \"4.1.1\",\n    \"@vitest/browser-preview\": \"4.1.1\",\n    \"@vitest/browser-webdriverio\": \"4.1.1\",\n    \"@vitest/expect\": \"4.1.1\",\n    \"@vitest/mocker\": \"4.1.1\",\n    \"@vitest/pretty-format\": \"4.1.1\",\n    \"@vitest/runner\": \"4.1.1\",\n    \"@vitest/snapshot\": \"4.1.1\",\n    \"@vitest/spy\": \"4.1.1\",\n    \"@vitest/utils\": \"4.1.1\",\n    \"chai\": \"^6.2.1\",\n    \"convert-source-map\": \"^2.0.0\",\n    \"estree-walker\": \"^3.0.3\",\n    \"expect-type\": \"^1.2.2\",\n    \"magic-string\": \"^0.30.21\",\n    \"oxc-parser\": \"catalog:\",\n    \"oxfmt\": \"catalog:\",\n    \"pathe\": \"^2.0.3\",\n    \"picomatch\": \"^4.0.3\",\n    \"rolldown\": \"workspace:*\",\n    \"rolldown-plugin-dts\": \"catalog:\",\n    \"tinyrainbow\": \"^3.0.3\",\n    \"vitest-dev\": \"^4.1.1\",\n    \"why-is-node-running\": \"^2.3.0\"\n  },\n  \"peerDependencies\": {\n    \"@edge-runtime/vm\": \"*\",\n    \"@opentelemetry/api\": \"^1.9.0\",\n    \"@types/node\": \"^20.0.0 || ^22.0.0 || >=24.0.0\",\n    \"@vitest/ui\": \"4.1.1\",\n    \"happy-dom\": \"*\",\n    \"jsdom\": \"*\",\n    \"vite\": \"^6.0.0 || ^7.0.0 || ^8.0.0\"\n  },\n  \"peerDependenciesMeta\": {\n    \"@edge-runtime/vm\": {\n      \"optional\": true\n    },\n    \"@opentelemetry/api\": {\n      \"optional\": true\n    },\n    \"@types/node\": {\n      \"optional\": true\n    },\n    \"@vitest/ui\": {\n      \"optional\": true\n    },\n    \"happy-dom\": {\n      \"optional\": true\n    },\n    \"jsdom\": {\n      \"optional\": true\n    },\n    \"vite\": {\n      \"optional\": false\n    }\n  },\n  \"engines\": {\n    \"node\": \"^20.0.0 || ^22.0.0 || >=24.0.0\"\n  },\n  \"bundledVersions\": {\n    \"vitest\": \"4.1.1\"\n  }\n}\n"
  },
  {
    "path": "packages/test/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"experimentalDecorators\": true\n  },\n  \"files\": [],\n  \"include\": [],\n  \"exclude\": [\"**/*\"]\n}\n"
  },
  {
    "path": "packages/tools/.upstream-versions.json",
    "content": "{\n  \"rolldown\": {\n    \"repo\": \"https://github.com/rolldown/rolldown.git\",\n    \"branch\": \"main\",\n    \"hash\": \"cbc94c4e97c19a4b2f4d739fdd054abaa038402c\"\n  },\n  \"vite\": {\n    \"repo\": \"https://github.com/vitejs/vite.git\",\n    \"branch\": \"main\",\n    \"hash\": \"faeb746721da80689d2cb62b589cc45edb779bdc\"\n  }\n}\n"
  },
  {
    "path": "packages/tools/README.md",
    "content": "# tools for internal development use\n\n- json-edit: A CLI tool to edit JSON files such as package.json in e2e tests\n- json-sort: A CLI tool to sort JSON keys in a file\n- snap-test: run snapshot tests for CLI\n"
  },
  {
    "path": "packages/tools/package.json",
    "content": "{\n  \"name\": \"@voidzero-dev/vite-plus-tools\",\n  \"private\": true,\n  \"bin\": {\n    \"json-edit\": \"./src/json-edit.ts\",\n    \"tool\": \"./src/bin.js\"\n  },\n  \"type\": \"module\",\n  \"scripts\": {\n    \"snap-test\": \"tool snap-test\"\n  },\n  \"dependencies\": {\n    \"@yarnpkg/fslib\": \"catalog:\",\n    \"@yarnpkg/shell\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"@oxc-node/cli\": \"catalog:\",\n    \"@oxc-node/core\": \"catalog:\",\n    \"@types/semver\": \"catalog:\",\n    \"@voidzero-dev/vite-plus-test\": \"workspace:*\",\n    \"minimatch\": \"catalog:\",\n    \"semver\": \"catalog:\",\n    \"yaml\": \"catalog:\"\n  }\n}\n"
  },
  {
    "path": "packages/tools/snap-tests/json-sort/array.json",
    "content": "[\n  {\n    \"name\": \"test\",\n    \"age\": 18\n  },\n  {\n    \"name\": \"abc\",\n    \"age\": 20\n  },\n  {\n    \"name\": \"def\",\n    \"age\": 15\n  },\n  {\n    \"name\": \"ghi\",\n    \"age\": 18\n  }\n]\n"
  },
  {
    "path": "packages/tools/snap-tests/json-sort/snap.txt",
    "content": "> cat array.json # should show original array.json file\n[\n  {\n    \"name\": \"test\",\n    \"age\": 18\n  },\n  {\n    \"name\": \"abc\",\n    \"age\": 20\n  },\n  {\n    \"name\": \"def\",\n    \"age\": 15\n  },\n  {\n    \"name\": \"ghi\",\n    \"age\": 18\n  }\n]\n\n> tool json-sort array.json '_.name' && cat array.json # should sort array.json file by name\n[\n  {\n    \"name\": \"abc\",\n    \"age\": 20\n  },\n  {\n    \"name\": \"def\",\n    \"age\": 15\n  },\n  {\n    \"name\": \"ghi\",\n    \"age\": 18\n  },\n  {\n    \"name\": \"test\",\n    \"age\": 18\n  }\n]\n\n> tool json-sort array.json '_.age' && cat array.json # should sort array.json file by age\n[\n  {\n    \"name\": \"def\",\n    \"age\": 15\n  },\n  {\n    \"name\": \"ghi\",\n    \"age\": 18\n  },\n  {\n    \"name\": \"test\",\n    \"age\": 18\n  },\n  {\n    \"name\": \"abc\",\n    \"age\": 20\n  }\n]\n"
  },
  {
    "path": "packages/tools/snap-tests/json-sort/steps.json",
    "content": "{\n  \"ignoredPlatforms\": [\"win32\"],\n  \"env\": {},\n  \"commands\": [\n    \"cat array.json # should show original array.json file\",\n    \"tool json-sort array.json '_.name' && cat array.json # should sort array.json file by name\",\n    \"tool json-sort array.json '_.age' && cat array.json # should sort array.json file by age\"\n  ]\n}\n"
  },
  {
    "path": "packages/tools/snap-tests/replace-file-content/foo/example.toml",
    "content": "[package]\nname = \"foo\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[[bin]]\nname = \"vite\"\npath = \"src/main.rs\"\n\n[dependencies]\nclap = { workspace = true, features = [\"derive\"] }\ncrossterm = { workspace = true }\nnapi = { workspace = true }\nnapi-derive = { workspace = true }\n"
  },
  {
    "path": "packages/tools/snap-tests/replace-file-content/snap.txt",
    "content": "> cat foo/example.toml # should show original toml file\n[package]\nname = \"foo\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[[bin]]\nname = \"vite\"\npath = \"src/main.rs\"\n\n[dependencies]\nclap = { workspace = true, features = [\"derive\"] }\ncrossterm = { workspace = true }\nnapi = { workspace = true }\nnapi-derive = { workspace = true }\n\n> tool replace-file-content foo/example.toml 'version = \"0.0.0\"' 'version = \"1.0.0\"' && cat foo/example.toml # should edit toml file\n[package]\nname = \"foo\"\nversion = \"1.0.0\"\nedition = \"2024\"\n\n[[bin]]\nname = \"vite\"\npath = \"src/main.rs\"\n\n[dependencies]\nclap = { workspace = true, features = [\"derive\"] }\ncrossterm = { workspace = true }\nnapi = { workspace = true }\nnapi-derive = { workspace = true }\n"
  },
  {
    "path": "packages/tools/snap-tests/replace-file-content/steps.json",
    "content": "{\n  \"env\": {},\n  \"commands\": [\n    \"cat foo/example.toml # should show original toml file\",\n    \"tool replace-file-content foo/example.toml 'version = \\\"0.0.0\\\"' 'version = \\\"1.0.0\\\"' && cat foo/example.toml # should edit toml file\"\n  ]\n}\n"
  },
  {
    "path": "packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`replaceUnstableOutput() > replace date 1`] = `\n\"Start at  <date>\n<date>\"\n`;\n\nexports[`replaceUnstableOutput() > replace full datetime (YYYY-MM-DD HH:MM:SS) 1`] = `\n\"Installed: <date>\n  Created: <date>\n  Updated: <date>\"\n`;\n\nexports[`replaceUnstableOutput() > replace hash values 1`] = `\n\"npm notice shasum: <hash>\nnpm notice integrity: sha512-<hash>\n\"shasum\": \"<hash>\",\n\"integrity\": \"sha512-<hash>\",\"\n`;\n\nexports[`replaceUnstableOutput() > replace ignore npm audited packages log 1`] = `\n\"removed 1 package in <variable>ms\nup to date in <variable>ms\nadded 1 package in <variable>ms\nadded 3 packages in <variable>ms\nDone in <variable>ms\"\n`;\n\nexports[`replaceUnstableOutput() > replace ignore npm notice access token expired or revoked warning log 1`] = `\n\"line 1\nline 2\nline 3\"\n`;\n\nexports[`replaceUnstableOutput() > replace ignore npm registry domain 1`] = `\n\"https://registry.<domain>/testnpm2\nhttps://registry.<domain>/debug\nhttps://registry.<domain>/testnpm2/-/testnpm2-1.0.0.tgz\n\"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.0.tgz\",\n\"resolved\": \"https://registry.<domain>/testnpm2/-/testnpm2-1.0.0.tgz\",\"\n`;\n\nexports[`replaceUnstableOutput() > replace ignore npm warn exec The following package was not found and will be installed: cowsay@<semver> warning log 1`] = `\"hello world\"`;\n\nexports[`replaceUnstableOutput() > replace ignore pnpm request warning log 1`] = `\n\"Foo bar\nPackages:\"\n`;\n\nexports[`replaceUnstableOutput() > replace ignore tarball download average speed warning log 1`] = `\n\"WARN  Tarball download average speed 29 KiB/s (size 56 KiB) is below 50 KiB/s: https://registry.<domain>/qs/-/qs-6.14.0.tgz (GET)\n WARN  Tarball download average speed 34 KiB/s (size 347 KiB) is below 50 KiB/s: https://registry.<domain>/undici/-/undici-7.16.0.tgz (GET)\"\n`;\n\nexports[`replaceUnstableOutput() > replace pnpm registry request error warning log 1`] = `\"Progress: resolved\"`;\n\nexports[`replaceUnstableOutput() > replace tsdown output 1`] = `\n\"ℹ tsdown v<semver> powered by rolldown v<semver>\nℹ entry: src/index.ts\nℹ Build start\nℹ dist/index.js  <variable> kB │ gzip: <variable> kB\nℹ 1 files, total: <variable> kB\n✔ Build complete in <variable>ms\"\n`;\n\nexports[`replaceUnstableOutput() > replace unstable cwd 1`] = `\"<cwd>/foo.txt\"`;\n\nexports[`replaceUnstableOutput() > replace unstable pnpm install output 1`] = `\n\"Scope: all <variable> workspace projects\nPackages: +<variable>\n+<repeat>\nProgress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done\n\ndevDependencies:\n+ vite-plus <semver>\n+ vitest <semver>\"\n`;\n\nexports[`replaceUnstableOutput() > replace unstable pnpm install output 2`] = `\n\"Scope: all <variable> workspace projects\nLockfile is up to date, resolution step is skipped\nAlready up to date\n\n╭ Warning ───────────────────────────────────────────────────────────────────────────────────╮\n│                                                                                            │\n│   Ignored build scripts: esbuild.                                                          │\n│   Run \"pnpm approve-builds\" to pick which dependencies should be allowed to run scripts.   │\n│                                                                                            │\n╰────────────────────────────────────────────────────────────────────────────────────────────╯\n\nDone in <variable>ms using pnpm v<semver>\"\n`;\n\nexports[`replaceUnstableOutput() > replace unstable semver version 1`] = `\n\"foo v<semver>\n v<semver>\n v<semver>\n <semver>\n <semver>\n <semver>\ntsdown/<semver>\nvitest/<semver>\nfoo/v<semver>\nfoo@<semver>\nbar@v<semver>\"\n`;\n\nexports[`replaceUnstableOutput() > replace unstable tmpdir with realpath 1`] = `\n\"<cwd>/foo.txt\n<cwd>/../other/bar.txt\"\n`;\n\nexports[`replaceUnstableOutput() > replace unstable vite-plus hash version 1`] = `\n\"\"vite-plus\": \"^0.0.0-<hash>\"\n\"vite-plus-core\": \"^0.0.0-<hash>\"\"\n`;\n\nexports[`replaceUnstableOutput() > replace vite-plus home paths 1`] = `\n\"<vite-plus-home>/js_runtime/node/v<semver>/bin/node\n<vite-plus-home>/packages/cowsay/lib/node_modules/cowsay/./cli.js\n<vite-plus-home>\n<vite-plus-home>/bin\"\n`;\n\nexports[`replaceUnstableOutput() > replace yarn YN0000: └ Completed with duration to empty string 1`] = `\n\"➤ YN0000: └ Completed\n➤ YN0000: └ Completed\n➤ YN0000: └ Completed\"\n`;\n\nexports[`replaceUnstableOutput() > replace yarn YN0013 1`] = `\n\"➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\"\n`;\n"
  },
  {
    "path": "packages/tools/src/__tests__/utils.spec.ts",
    "content": "import { randomUUID } from 'node:crypto';\nimport fs from 'node:fs';\nimport { homedir, tmpdir } from 'node:os';\nimport path from 'node:path';\n\nimport { describe, expect, test } from '@voidzero-dev/vite-plus-test';\n\nimport { isPassThroughEnv, replaceUnstableOutput } from '../utils';\n\ndescribe('replaceUnstableOutput()', () => {\n  test('strip ANSI escape sequences', () => {\n    const output = '\\u001b[1m\\u001b[2mnote:\\u001b[0m\\u001b[0m yarn@2+ uses upgrade-interactive';\n    expect(replaceUnstableOutput(output)).toBe('note: yarn@2+ uses upgrade-interactive');\n  });\n\n  test('normalize CRLF line endings', () => {\n    const output = 'line 1\\r\\nline 2\\r\\nline 3\\r';\n    expect(replaceUnstableOutput(output)).toBe('line 1\\nline 2\\nline 3');\n  });\n\n  test('replace unstable semver version', () => {\n    const output = `\nfoo v1.0.0\n v1.0.0-beta.1\n v1.0.0-beta.1+build.1\n 1.0.0\n 1.0.0-beta.1\n 1.0.0-beta.1+build.1\ntsdown/0.15.1\nvitest/3.2.4\nfoo/v100.1.1000\nfoo@1.0.0\nbar@v1.0.0\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace date', () => {\n    const output = `\nStart at  15:01:23\n15:01:23\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace full datetime (YYYY-MM-DD HH:MM:SS)', () => {\n    const output = `\n  Installed: 2026-02-04 15:30:45\n  Created: 2024-01-15 10:30:00\n  Updated: 1999-12-31 23:59:59\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace parenthesized thread counts', () => {\n    const output = `\npass: All 3 files are correctly formatted (88ms, 2 threads)\npass: Found no warnings or lint errors in 1 file (<variable>ms, 16 threads)\n    `;\n    expect(replaceUnstableOutput(output.trim())).toBe(\n      [\n        'pass: All 3 files are correctly formatted (<variable>ms, <variable> threads)',\n        'pass: Found no warnings or lint errors in 1 file (<variable>ms, <variable> threads)',\n      ].join('\\n'),\n    );\n  });\n\n  test('replace unstable pnpm install output', () => {\n    const outputs = [\n      `\nScope: all 6 workspace projects\nPackages: +312\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\nProgress: resolved 1, reused 0, downloaded 0, added 0\nProgress: resolved 316, reused 316, downloaded 0, added 315\nWARN  Skip adding vite to the default catalog because it already exists as npm:vite-plus. Please use \\`pnpm update\\` to update the catalogs.\nWARN  Skip adding vitest to the default catalog because it already exists as beta. Please use \\`pnpm update\\` to update the catalogs.\nProgress: resolved 316, reused 316, downloaded 0, added 316, done\n\ndevDependencies:\n+ vite-plus 0.0.0-8a4f4936e0eca32dd57e1a503c2b09745953344d\n+ vitest 3.2.4\n      `,\n      `\nScope: all 2 workspace projects\nLockfile is up to date, resolution step is skipped\nAlready up to date\n\n╭ Warning ───────────────────────────────────────────────────────────────────────────────────╮\n│                                                                                            │\n│   Ignored build scripts: esbuild.                                                          │\n│   Run \"pnpm approve-builds\" to pick which dependencies should be allowed to run scripts.   │\n│                                                                                            │\n╰────────────────────────────────────────────────────────────────────────────────────────────╯\n\nDone in 171ms using pnpm v10.16.1\n      `,\n    ];\n    for (const output of outputs) {\n      expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n    }\n  });\n\n  test.skipIf(process.platform === 'win32')('replace unstable cwd', () => {\n    const cwd = tmpdir();\n    const output = path.join(cwd, 'foo.txt');\n    expect(replaceUnstableOutput(output.trim(), cwd)).toMatchSnapshot();\n  });\n\n  test.skipIf(process.platform === 'win32')('replace unstable tmpdir with realpath', () => {\n    const tmp = fs.realpathSync(tmpdir());\n    const cwd = path.join(tmp, `vite-plus-unittest-${randomUUID()}`);\n    const output = `${path.join(cwd, 'foo.txt')}\\n${path.join(cwd, '../other/bar.txt')}`;\n    expect(replaceUnstableOutput(output.trim(), cwd)).toMatchSnapshot();\n  });\n\n  describe.skipIf(process.platform !== 'win32')('Windows cwd replacement', () => {\n    test('mixed-separator cwd matches all-backslash output', () => {\n      // Simulates the CI failure: cwd has mixed separators (template literal),\n      // but Vite outputs all-backslash paths (path.resolve)\n      const cwd =\n        'C:\\\\Users\\\\RUNNER~1\\\\AppData\\\\Local\\\\Temp/vite-plus-test-abc/command-staged-broken-config';\n      const output =\n        'failed to load config from C:\\\\Users\\\\RUNNER~1\\\\AppData\\\\Local\\\\Temp\\\\vite-plus-test-abc\\\\command-staged-broken-config\\\\vite.config.ts';\n      expect(replaceUnstableOutput(output, cwd)).toBe(\n        'failed to load config from <cwd>/vite.config.ts',\n      );\n    });\n\n    test('all-backslash cwd matches all-backslash output', () => {\n      const cwd = 'C:\\\\Users\\\\runner\\\\project';\n      const output = 'error in C:\\\\Users\\\\runner\\\\project\\\\src\\\\main.ts';\n      expect(replaceUnstableOutput(output, cwd)).toBe('error in <cwd>/src/main.ts');\n    });\n\n    test('cwd at end of string without trailing separator', () => {\n      const cwd = 'C:\\\\Users\\\\runner\\\\project';\n      const output = 'path is C:\\\\Users\\\\runner\\\\project';\n      expect(replaceUnstableOutput(output, cwd)).toBe('path is <cwd>');\n    });\n\n    test('parent directory replacement with backslash paths', () => {\n      const cwd = 'C:\\\\Users\\\\RUNNER~1\\\\Temp/vite-plus-test/my-test';\n      const output = 'found C:\\\\Users\\\\RUNNER~1\\\\Temp\\\\vite-plus-test\\\\other\\\\file.ts';\n      expect(replaceUnstableOutput(output, cwd)).toBe('found <cwd>/../other/file.ts');\n    });\n  });\n\n  test('replace tsdown output', () => {\n    const output = `\nℹ tsdown v0.15.1 powered by rolldown v0.15.1\nℹ entry: src/index.ts\nℹ Build start\nℹ dist/index.js  0.15 kB │ gzip: 0.12 kB\nℹ 1 files, total: 0.15 kB\n✔ Build complete in 100ms\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace yarn YN0013', () => {\n    const output = `\n➤ YN0000: ┌ Fetch step\n➤ YN0013: │ A package was added to the project (+ 0.7 KiB).\n➤ YN0000: └ Completed\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace yarn YN0000: └ Completed with duration to empty string', () => {\n    const output = `\n➤ YN0000: └ Completed in 100ms\n➤ YN0000: └ Completed in 100ms 200ms\n➤ YN0000: └ Completed\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace ignore pnpm request warning log', () => {\n    const output = `\nFoo bar\n WARN  Request took <variable>ms: https://registry.npmjs.org/testnpm2\nPackages:\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace ignore npm audited packages log', () => {\n    const output = `\nremoved 1 package, and audited 3 packages in 700ms\nup to date, audited 4 packages in 11ms\nadded 1 package, and audited 3 packages in 700ms\nadded 3 packages, and audited 4 packages in 100ms\n\nfound 0 vulnerabilities\nDone in 1000ms\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace ignore npm registry domain', () => {\n    const output = `\nhttps://registry.npmjs.org/testnpm2\nhttps://registry.yarnpkg.com/debug\nhttps://registry.yarnpkg.com/testnpm2/-/testnpm2-1.0.0.tgz\n\"resolved\": \"https://registry.yarnpkg.com/testnpm2/-/testnpm2-1.0.0.tgz\",\n\"resolved\": \"https://registry.npmjs.org/testnpm2/-/testnpm2-1.0.0.tgz\",\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace pnpm registry request error warning log', () => {\n    const output = `\n WARN  GET https://registry.npmjs.org/test-vite-plus-install error (ECONNRESET). Will retry in 10 seconds. 2 retries left.\nProgress: resolved\n`;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace ignore tarball download average speed warning log', () => {\n    const output = `\n WARN  Tarball download average speed 29 KiB/s (size 56 KiB) is below 50 KiB/s: https://registry.npmjs.org/qs/-/qs-6.14.0.tgz (GET)\n WARN  Tarball download average speed 34 KiB/s (size 347 KiB) is below 50 KiB/s: https://registry.npmjs.org/undici/-/undici-7.16.0.tgz (GET)\n`;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace hash values', () => {\n    const output = `\nnpm notice shasum: 65c35f9599054722ecde040abd4a19682a723cdc\nnpm notice integrity: sha512-qugLL42iCblSD[...]Gfk6HJodp2ZOQ==\n\"shasum\": \"65c35f9599054722ecde040abd4a19682a723cdc\",\n\"integrity\": \"sha512-qugLL42iCblSDO0Vwic9xYkKYNtf+MwPW4cQSppKbGtQ/xswl1gXyu/DF5b7I/WbsVi02DJIHGfk6HJodp2ZOQ==\",\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace ignore npm notice access token expired or revoked warning log', () => {\n    const output = `\nline 1\nnpm notice Access token expired or revoked. Please try logging in again.\nnpm notice Access token expired or revoked. Please try logging in again.\nline 2\nnpm notice Access token expired or revoked. Please try logging in again.\nline 3\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test('replace unstable vite-plus hash version', () => {\n    const output = `\n\"vite-plus\": \"^0.0.0-aa9f90fe23216b8ad85b0ba4fc1bccb0614afaf0\"\n\"vite-plus-core\": \"^0.0.0-43b91ac4e4bc63ba78dee8a813806bdbaa7a4378\"\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n\n  test.skipIf(process.platform === 'win32')('replace vite-plus home paths', () => {\n    const home = homedir();\n    const output = [\n      `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`,\n      `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`,\n      `${home}/.vite-plus`,\n      `${home}/.vite-plus/bin`,\n    ].join('\\n');\n    expect(replaceUnstableOutput(output)).toMatchSnapshot();\n  });\n\n  test('replace ignore npm warn exec The following package was not found and will be installed: cowsay@<semver> warning log', () => {\n    const output = `\nnpm warn exec The following package was not found and will be installed: cowsay@<semver>\nnpm warn exec The following package was not found and will be installed: cowsay@1.6.0\nhello world\n    `;\n    expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();\n  });\n});\n\ndescribe('isPassThroughEnv()', () => {\n  test('should return true if env is pass-through', () => {\n    expect(isPassThroughEnv('NPM_AUTH_TOKEN')).toBe(true);\n    expect(isPassThroughEnv('PATH')).toBe(true);\n  });\n\n  test('should return false if env is not pass-through', () => {\n    expect(isPassThroughEnv('NODE_ENV')).toBe(false);\n    expect(isPassThroughEnv('API_URL')).toBe(false);\n  });\n});\n"
  },
  {
    "path": "packages/tools/src/bin.js",
    "content": "import '@oxc-node/core/register';\n\n// defer the import to avoid the register hook is not being called\nawait import('./index.ts');\n"
  },
  {
    "path": "packages/tools/src/brand-vite.ts",
    "content": "/**\n * Apply Vite+ branding patches to vite source after sync.\n *\n * This script modifies user-visible branding strings in the vite\n * source to show \"VITE+\" instead of \"VITE\". It is called automatically\n * at the end of `sync-remote-deps.ts` and can also be run independently.\n *\n * Changes applied:\n * 1. constants.ts: Add VITE_PLUS_VERSION constant\n * 2. cli.ts: Import VITE_PLUS_VERSION, change CLI name/version, and make banner blue\n * 3. build.ts: Remove startup build banner and change error prefix\n * 4. logger.ts: Change default prefix from '[vite]' to '[vite+]'\n * 5. plugins/reporter.ts: Suppress redundant \"vite v<version>\" native reporter line\n */\n\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nconst VITE_DIR = 'vite';\nconst VITE_NODE_DIR = join(VITE_DIR, 'packages', 'vite', 'src', 'node');\n\nfunction log(message: string) {\n  console.log(`[brand-vite] ${message}`);\n}\n\n/**\n * Replace a string in a file.\n * Returns 'patched' if the replacement was applied, 'already' if the replacement\n * text is already present, or throws an error if neither the search nor the\n * replacement text is found (upstream code changed).\n */\nfunction replaceInFile(\n  filePath: string,\n  search: string,\n  replacement: string,\n): 'patched' | 'already' {\n  const content = readFileSync(filePath, 'utf-8');\n  // Check replacement first: the search string may be a substring of the replacement\n  // (e.g. constants.ts where the replacement appends lines after the search string).\n  if (content.includes(replacement)) {\n    return 'already';\n  }\n  if (content.includes(search)) {\n    const newContent = content.replace(search, replacement);\n    writeFileSync(filePath, newContent, 'utf-8');\n    return 'patched';\n  }\n  throw new Error(\n    `[brand-vite] Patch failed in ${filePath}:\\n` +\n      `  Could not find search string: ${JSON.stringify(search)}\\n` +\n      `  The upstream code may have changed. Please update the search string in brand-vite.ts.`,\n  );\n}\n\nfunction removeAnyInFile(\n  filePath: string,\n  searches: Array<string | RegExp>,\n): 'patched' | 'already' {\n  const content = readFileSync(filePath, 'utf-8');\n  for (const search of searches) {\n    if (typeof search === 'string') {\n      if (content.includes(search)) {\n        const newContent = content.replace(search, '');\n        writeFileSync(filePath, newContent, 'utf-8');\n        return 'patched';\n      }\n      continue;\n    }\n\n    if (search.test(content)) {\n      const newContent = content.replace(search, '');\n      writeFileSync(filePath, newContent, 'utf-8');\n      return 'patched';\n    }\n  }\n  return 'already';\n}\n\nfunction logPatch(file: string, desc: string, result: 'patched' | 'already') {\n  if (result === 'patched') {\n    log(`  ✓ ${file}: ${desc}`);\n  } else {\n    log(`  - ${file}: Already patched`);\n  }\n}\n\nexport function brandVite(rootDir: string = process.cwd()) {\n  log('Applying Vite+ branding patches...');\n\n  const nodeDir = join(rootDir, VITE_NODE_DIR);\n\n  // 1. constants.ts: Add VITE_PLUS_VERSION constant after VERSION\n  const constantsFile = join(nodeDir, 'constants.ts');\n  logPatch(\n    'constants.ts',\n    'Added VITE_PLUS_VERSION',\n    replaceInFile(\n      constantsFile,\n      'export const VERSION = version as string',\n      'export const VERSION = version as string\\n\\nexport const VITE_PLUS_VERSION: string = process.env.VITE_PLUS_VERSION || VERSION',\n    ),\n  );\n\n  // 2. cli.ts: Import VITE_PLUS_VERSION, change CLI name, version, and dev banner\n  const cliFile = join(nodeDir, 'cli.ts');\n  const cliResults = [\n    replaceInFile(\n      cliFile,\n      \"import { VERSION } from './constants'\",\n      \"import { VERSION, VITE_PLUS_VERSION } from './constants'\",\n    ),\n    replaceInFile(cliFile, \"cac('vite')\", \"cac('vp')\"),\n    replaceInFile(cliFile, 'cli.version(VERSION)', 'cli.version(VITE_PLUS_VERSION)'),\n    replaceInFile(\n      cliFile,\n      \"`${colors.bold('VITE')} v${VERSION}`\",\n      \"`${colors.bold('VITE+')} v${VITE_PLUS_VERSION}`\",\n    ),\n    replaceInFile(\n      cliFile,\n      \"colors.green(\\n            `${colors.bold('VITE+')} v${VITE_PLUS_VERSION}`,\\n          )\",\n      \"colors.blue(\\n            `${colors.bold('VITE+')} v${VITE_PLUS_VERSION}`,\\n          )\",\n    ),\n  ];\n  logPatch(\n    'cli.ts',\n    'Updated imports, CLI name, version, and banner',\n    cliResults.includes('patched') ? 'patched' : 'already',\n  );\n\n  // 3. build.ts: Remove startup build banner and update error prefix\n  const buildFile = join(nodeDir, 'build.ts');\n  const buildResults = [\n    removeAnyInFile(buildFile, [\n      / {4}logger\\.info\\(\\n {6}colors\\.[a-zA-Z]+\\(\\n {8}`vite v\\$\\{VERSION\\} \\$\\{colors\\.green\\(\\n {10}`building \\$\\{environment\\.name\\} environment for \\$\\{environment\\.config\\.mode\\}\\.\\.\\.`,\\n {8}\\)\\}`,\\n {6}\\),\\n {4}\\)\\n/,\n      / {4}logger\\.info\\(\\n {6}colors\\.[a-zA-Z]+\\(\\n {8}`vite\\+ v\\$\\{VITE_PLUS_VERSION\\} \\$\\{colors\\.green\\(\\n {10}`building \\$\\{environment\\.name\\} environment for \\$\\{environment\\.config\\.mode\\}\\.\\.\\.`,\\n {8}\\)\\}`,\\n {6}\\),\\n {4}\\)\\n/,\n    ]),\n    replaceInFile(buildFile, '`[vite]: Rolldown failed', '`[vite+]: Rolldown failed'),\n  ];\n  logPatch(\n    'build.ts',\n    'Removed startup banner and updated error prefix',\n    buildResults.includes('patched') ? 'patched' : 'already',\n  );\n\n  // 4. logger.ts: Change default prefix\n  const loggerFile = join(nodeDir, 'logger.ts');\n  logPatch(\n    'logger.ts',\n    \"Changed prefix to '[vite+]'\",\n    replaceInFile(loggerFile, \"prefix = '[vite]'\", \"prefix = '[vite+]'\"),\n  );\n\n  // 5. reporter.ts: Suppress redundant version-only line from native reporter\n  const reporterFile = join(nodeDir, 'plugins', 'reporter.ts');\n  const reporterResults = [\n    replaceInFile(\n      reporterFile,\n      \"import path from 'node:path'\",\n      \"import path from 'node:path'\\n\\nconst VITE_VERSION_ONLY_LINE_RE = /^vite v\\\\S+$/\",\n    ),\n    replaceInFile(\n      reporterFile,\n      '      logInfo: shouldLogInfo ? (msg) => env.logger.info(msg) : undefined,',\n      '      logInfo: shouldLogInfo\\n        ? (msg) => {\\n            // Keep transformed/chunk/gzip logs but suppress redundant version-only line.\\n            if (VITE_VERSION_ONLY_LINE_RE.test(msg.trim())) {\\n              return\\n            }\\n            env.logger.info(msg)\\n          }\\n        : undefined,',\n    ),\n  ];\n  logPatch(\n    'plugins/reporter.ts',\n    'Suppressed redundant version-only native reporter line',\n    reporterResults.includes('patched') ? 'patched' : 'already',\n  );\n\n  log('Done!');\n}\n"
  },
  {
    "path": "packages/tools/src/index.ts",
    "content": "const subcommand = process.argv[2];\n\nswitch (subcommand) {\n  case 'snap-test':\n    const { snapTest } = await import('./snap-test.ts');\n    await snapTest();\n    break;\n  case 'replace-file-content':\n    const { replaceFileContent } = await import('./replace-file-content.ts');\n    replaceFileContent();\n    break;\n  case 'sync-remote':\n    const { syncRemote } = await import('./sync-remote-deps.ts');\n    await syncRemote();\n    break;\n  case 'json-sort':\n    const { jsonSort } = await import('./json-sort.ts');\n    jsonSort();\n    break;\n  case 'merge-peer-deps':\n    const { mergePeerDeps } = await import('./merge-peer-deps.ts');\n    mergePeerDeps();\n    break;\n  case 'install-global-cli':\n    const { installGlobalCli } = await import('./install-global-cli.ts');\n    installGlobalCli();\n    break;\n  case 'brand-vite':\n    const { brandVite } = await import('./brand-vite.ts');\n    brandVite();\n    break;\n  default:\n    console.error(`Unknown subcommand: ${subcommand}`);\n    console.error(\n      'Available subcommands: snap-test, replace-file-content, sync-remote, json-sort, merge-peer-deps, install-global-cli, brand-vite',\n    );\n    process.exit(1);\n}\n\n// Can't use top-level await if the file is not a module\n"
  },
  {
    "path": "packages/tools/src/install-global-cli.ts",
    "content": "import { execSync } from 'node:child_process';\nimport {\n  existsSync,\n  mkdirSync,\n  mkdtempSync,\n  readFileSync,\n  readdirSync,\n  rmSync,\n  symlinkSync,\n  writeFileSync,\n} from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { parseArgs } from 'node:util';\n\nconst isWindows = process.platform === 'win32';\nconst LOCAL_DEV_PREFIX = 'local-dev';\nconst pad2 = (n: number) => n.toString().padStart(2, '0');\n\nfunction localDevVersion(): string {\n  const now = new Date();\n  const date = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}`;\n  const time = `${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;\n  return `${LOCAL_DEV_PREFIX}-${date}-${time}`;\n}\n\n// Get repo root from script location (packages/tools/src/install-global-cli.ts -> repo root)\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\nconst repoRoot = path.resolve(__dirname, '../../..');\n\nexport function installGlobalCli() {\n  // Detect if running directly or via tools dispatcher\n  const isDirectInvocation = process.argv[1]?.endsWith('install-global-cli.ts');\n  const args = process.argv.slice(isDirectInvocation ? 2 : 3);\n\n  const { values } = parseArgs({\n    allowPositionals: false,\n    args,\n    options: {\n      tgz: {\n        type: 'string',\n        short: 't',\n      },\n    },\n  });\n\n  console.log('Installing global CLI: vp');\n\n  let tempDir: string | undefined;\n  let tgzPath: string;\n\n  if (values.tgz) {\n    // Use provided tgz file directly\n    tgzPath = path.resolve(values.tgz);\n    if (!existsSync(tgzPath)) {\n      console.error(`Error: tgz file not found: ${tgzPath}`);\n      process.exit(1);\n    }\n    console.log(`Using provided tgz: ${tgzPath}`);\n  } else {\n    // Create temp directory for pnpm pack output\n    tempDir = mkdtempSync(path.join(os.tmpdir(), 'vite-plus-'));\n\n    // Use pnpm pack to create tarball\n    // - Auto-resolves catalog: dependencies\n    execSync(`pnpm pack --pack-destination \"${tempDir}\"`, {\n      cwd: path.join(repoRoot, 'packages/cli'),\n      stdio: 'inherit',\n    });\n\n    // Find the generated tgz file (name includes version)\n    const tgzFile = readdirSync(tempDir).find((f) => f.endsWith('.tgz'));\n    if (!tgzFile) {\n      throw new Error('pnpm pack did not create a .tgz file');\n    }\n    tgzPath = path.join(tempDir, tgzFile);\n  }\n\n  try {\n    const installDir = path.join(os.homedir(), '.vite-plus');\n\n    // Locate the Rust vp binary (built by cargo or copied by CI)\n    const binaryName = isWindows ? 'vp.exe' : 'vp';\n    const binaryPath = findVpBinary(binaryName);\n    if (!binaryPath) {\n      console.error(`Error: vp binary not found in ${path.join(repoRoot, 'target')}`);\n      console.error('Run \"cargo build -p vite_global_cli --release\" first.');\n      process.exit(1);\n    }\n\n    // On Windows, the trampoline shim binary is required for creating shims.\n    // Validate it exists beside the chosen vp.exe to avoid mismatched artifacts.\n    if (isWindows) {\n      const shimPath = path.join(path.dirname(binaryPath), 'vp-shim.exe');\n      if (!existsSync(shimPath)) {\n        console.error(`Error: vp-shim.exe not found at ${shimPath}`);\n        console.error('Build it with: cargo build -p vite_trampoline --release');\n        process.exit(1);\n      }\n    }\n\n    const localDevVer = localDevVersion();\n\n    // Clean up old local-dev directories to avoid accumulation\n    if (existsSync(installDir)) {\n      for (const entry of readdirSync(installDir)) {\n        if (entry.startsWith(LOCAL_DEV_PREFIX)) {\n          try {\n            rmSync(path.join(installDir, entry), { recursive: true, force: true });\n          } catch (err) {\n            console.warn(`Warning: failed to remove old ${entry}: ${(err as Error).message}`);\n          }\n        }\n      }\n    }\n\n    const env: Record<string, string> = {\n      ...(process.env as Record<string, string>),\n      VITE_PLUS_LOCAL_TGZ: tgzPath,\n      VITE_PLUS_LOCAL_BINARY: binaryPath,\n      VITE_PLUS_HOME: installDir,\n      VITE_PLUS_VERSION: localDevVer,\n      CI: 'true',\n      // Skip vp install in install.sh — we handle deps ourselves:\n      // - Local dev: symlink monorepo node_modules\n      // - CI (--tgz): rewrite @voidzero-dev/* deps to file: protocol and npm install\n      VITE_PLUS_SKIP_DEPS_INSTALL: '1',\n    };\n\n    // Run platform-specific install script (use absolute paths)\n    const installScriptDir = path.join(repoRoot, 'packages/cli');\n    if (isWindows) {\n      // Use pwsh (PowerShell Core) for better UTF-8 handling\n      const ps1Path = path.join(installScriptDir, 'install.ps1');\n      execSync(`pwsh -ExecutionPolicy Bypass -File \"${ps1Path}\"`, {\n        stdio: 'inherit',\n        env,\n      });\n    } else {\n      const shPath = path.join(installScriptDir, 'install.sh');\n      execSync(`bash \"${shPath}\"`, {\n        stdio: 'inherit',\n        env,\n      });\n    }\n\n    // Set up node_modules for local dev by rewriting workspace deps to file: protocol\n    // and running pnpm install. Production installs use `vp install` in install.sh directly.\n    const versionDir = path.join(installDir, localDevVer);\n    if (values.tgz) {\n      installCiDeps(versionDir, tgzPath);\n    } else {\n      setupLocalDevDeps(versionDir);\n    }\n  } finally {\n    // Cleanup temp dir only if we created it\n    if (tempDir) {\n      rmSync(tempDir, { recursive: true, force: true });\n    }\n  }\n}\n\n// Find the vp binary in the target directory.\n// Checks target/release/ first (local builds), then target/<triple>/release/ (cross-compiled CI builds).\nfunction findVpBinary(binaryName: string) {\n  // 1. Direct release build: target/release/vp\n  const directPath = path.join(repoRoot, 'target', 'release', binaryName);\n  if (existsSync(directPath)) {\n    return directPath;\n  }\n\n  // 2. Cross-compiled build: target/<triple>/release/vp (CI builds with --target)\n  const targetDir = path.join(repoRoot, 'target');\n  if (existsSync(targetDir)) {\n    for (const entry of readdirSync(targetDir)) {\n      const crossPath = path.join(targetDir, entry, 'release', binaryName);\n      if (existsSync(crossPath)) {\n        return crossPath;\n      }\n    }\n  }\n\n  return null;\n}\n\n/**\n * Install dependencies for CI by generating a wrapper package.json with file: protocol\n * references to the main tgz and sibling @voidzero-dev/* tgz files, then running npm install.\n */\nfunction installCiDeps(versionDir: string, mainTgzPath: string) {\n  const tgzDir = path.dirname(mainTgzPath);\n\n  // Extract vite-plus's package.json from the tgz to find @voidzero-dev/* deps\n  // On Windows, use the system tar (bsdtar) which handles Windows paths natively.\n  // Git Bash's GNU tar misinterprets drive letters (D:, C:) as remote host references,\n  // affecting both the archive path and the -C directory argument.\n  const tar = isWindows ? `\"${process.env.SystemRoot}\\\\System32\\\\tar.exe\"` : 'tar';\n  const tempDir = mkdtempSync(path.join(os.tmpdir(), 'vp-deps-'));\n  try {\n    execSync(`${tar} xzf \"${mainTgzPath}\" -C \"${tempDir}\" --strip-components=1 package.json`, {\n      stdio: 'pipe',\n    });\n  } catch {\n    // If extracting just package.json fails, extract everything\n    execSync(`${tar} xzf \"${mainTgzPath}\" -C \"${tempDir}\" --strip-components=1`, {\n      stdio: 'pipe',\n    });\n  }\n  const vitePlusPkg = JSON.parse(readFileSync(path.join(tempDir, 'package.json'), 'utf-8'));\n  rmSync(tempDir, { recursive: true, force: true });\n\n  // Build wrapper deps: vite-plus from tgz + @voidzero-dev/* from sibling tgz files\n  const wrapperDeps: Record<string, string> = {\n    'vite-plus': `file:${mainTgzPath}`,\n  };\n\n  const vitePlusDeps: Record<string, string> = vitePlusPkg.dependencies ?? {};\n  for (const [name, version] of Object.entries(vitePlusDeps)) {\n    if (!name.startsWith('@voidzero-dev/')) {\n      continue;\n    }\n    // @voidzero-dev/vite-plus-core@0.0.0 -> voidzero-dev-vite-plus-core-0.0.0.tgz\n    const tgzName = name.replace('@', '').replace('/', '-') + `-${version}.tgz`;\n    const tgzFilePath = path.join(tgzDir, tgzName);\n    if (existsSync(tgzFilePath)) {\n      wrapperDeps[name] = `file:${tgzFilePath}`;\n      console.log(`  ${name}: ${version} -> file:${tgzFilePath}`);\n    } else {\n      console.warn(`Warning: tgz not found for ${name}@${version}: ${tgzFilePath}`);\n    }\n  }\n\n  const wrapperPkg = {\n    name: 'vp-global',\n    version: '0.0.0',\n    private: true,\n    dependencies: wrapperDeps,\n  };\n\n  writeFileSync(path.join(versionDir, 'package.json'), JSON.stringify(wrapperPkg, null, 2) + '\\n');\n\n  execSync('npm install --no-audit --no-fund --legacy-peer-deps', {\n    cwd: versionDir,\n    stdio: 'inherit',\n  });\n}\n\n/**\n * Set up dependencies for local dev by symlinking into node_modules.\n *\n * Creates node_modules/vite-plus → packages/cli (source) and symlinks\n * transitive deps from packages/cli/node_modules into version_dir/node_modules.\n */\nfunction setupLocalDevDeps(versionDir: string) {\n  const nodeModulesDir = path.join(versionDir, 'node_modules');\n  rmSync(nodeModulesDir, { recursive: true, force: true });\n  mkdirSync(nodeModulesDir, { recursive: true });\n\n  // Symlink node_modules/vite-plus → packages/cli (source)\n  const cliDir = path.join(repoRoot, 'packages', 'cli');\n  const symlinkType = isWindows ? 'junction' : 'dir';\n  symlinkSync(cliDir, path.join(nodeModulesDir, 'vite-plus'), symlinkType);\n\n  // Symlink transitive deps from packages/cli/node_modules\n  const cliNodeModules = path.join(cliDir, 'node_modules');\n  if (!existsSync(cliNodeModules)) {\n    return;\n  }\n\n  for (const entry of readdirSync(cliNodeModules)) {\n    if (entry === '.pnpm' || entry === '.modules.yaml') {\n      continue;\n    }\n    const src = path.join(cliNodeModules, entry);\n    const dest = path.join(nodeModulesDir, entry);\n    if (!existsSync(dest)) {\n      // Handle scoped packages (@scope/) by creating parent dir\n      if (entry.startsWith('@')) {\n        mkdirSync(dest, { recursive: true });\n        for (const sub of readdirSync(src)) {\n          symlinkSync(path.join(src, sub), path.join(dest, sub), symlinkType);\n        }\n      } else {\n        symlinkSync(src, dest, symlinkType);\n      }\n    }\n  }\n}\n\n// Allow running directly via: npx tsx install-global-cli.ts <args>\nif (import.meta.main) {\n  installGlobalCli();\n}\n"
  },
  {
    "path": "packages/tools/src/json-edit.ts",
    "content": "#!/usr/bin/env node\n\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { parseArgs } from 'node:util';\n\nconst { positionals } = parseArgs({\n  allowPositionals: true,\n});\n\nconst filename = positionals[0];\nconst script = positionals[1];\n\nif (!filename || !script) {\n  console.error('Usage: json-edit <filename> <script>');\n  console.error('Example: json-edit package.json \\'_.version = \"1.2.3\"\\'');\n  process.exit(1);\n}\n\nconst json = JSON.parse(readFileSync(filename, 'utf-8'));\n// oxlint-disable-next-line typescript/no-implied-eval\nconst func = new Function('_', script + '; return _;');\nconst result = func(json);\n\nwriteFileSync(filename, JSON.stringify(result, null, 2) + '\\n', 'utf-8');\n"
  },
  {
    "path": "packages/tools/src/json-sort.ts",
    "content": "#!/usr/bin/env node\n\nimport assert from 'node:assert';\nimport { readFileSync, writeFileSync } from 'node:fs';\nimport { parseArgs } from 'node:util';\n\nexport function jsonSort() {\n  const { positionals } = parseArgs({\n    allowPositionals: true,\n    args: process.argv.slice(3),\n  });\n\n  const filename = positionals[0];\n  const script = positionals[1];\n\n  if (!filename || !script) {\n    console.error('Usage: tool json-sort <filename> <script>');\n    console.error(\"Example: tool json-sort array.json '_.name'\");\n    process.exit(1);\n  }\n\n  const data = JSON.parse(readFileSync(filename, 'utf-8'));\n  assert(Array.isArray(data), 'json data must be an array');\n  // sort json by script\n  // oxlint-disable-next-line typescript/no-implied-eval\n  const func = new Function('_', `return ${script};`);\n  const sortedJson = data.toSorted((a: unknown, b: unknown) => {\n    const aValue = func(a);\n    const bValue = func(b);\n    if (aValue < bValue) {\n      return -1;\n    }\n    if (aValue > bValue) {\n      return 1;\n    }\n    return 0;\n  });\n\n  writeFileSync(filename, JSON.stringify(sortedJson, null, 2) + '\\n', 'utf-8');\n}\n"
  },
  {
    "path": "packages/tools/src/merge-peer-deps.ts",
    "content": "import { existsSync, readFileSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport * as semver from 'semver';\n\ninterface PackageJson {\n  name?: string;\n  peerDependencies?: Record<string, string>;\n  peerDependenciesMeta?: Record<string, { optional?: boolean }>;\n  [key: string]: unknown;\n}\n\nfunction log(message: string) {\n  console.log(`[merge-peer-deps] ${message}`);\n}\n\nfunction error(message: string): never {\n  console.error(`[merge-peer-deps] ERROR: ${message}`);\n  process.exit(1);\n}\n\nconst getMajor = (range: string): number | null => {\n  const match = range.match(/(\\d+)\\./);\n  return match ? parseInt(match[1], 10) : null;\n};\n\nfunction mergeSemverVersions(v1: string, v2: string, packageName: string): string {\n  // Handle special cases\n  if (v1 === v2) {\n    return v1;\n  }\n\n  // Handle exact version specifiers (=)\n  const isExact1 = v1.startsWith('=');\n  const isExact2 = v2.startsWith('=');\n  if (isExact1 || isExact2) {\n    if (isExact1 && isExact2 && v1 !== v2) {\n      error(`Incompatible exact versions for ${packageName}: ${v1} vs ${v2}`);\n    }\n    return isExact1 ? v1 : v2;\n  }\n\n  // Handle npm: prefix\n  if (v1.startsWith('npm:') || v2.startsWith('npm:')) {\n    // If one has npm: prefix, prefer the non-npm version or return the first one\n    if (!v1.startsWith('npm:')) {\n      return v1;\n    }\n    if (!v2.startsWith('npm:')) {\n      return v2;\n    }\n    return v1;\n  }\n\n  // Handle workspace: prefix\n  if (v1.startsWith('workspace:') || v2.startsWith('workspace:')) {\n    if (v1.startsWith('workspace:')) {\n      return v1;\n    }\n    if (v2.startsWith('workspace:')) {\n      return v2;\n    }\n    return v1;\n  }\n\n  // Handle wildcards\n  if (v1 === '*' || v2 === '*') {\n    // Prefer specific version over wildcard\n    if (v1 === '*') {\n      return v2;\n    }\n    if (v2 === '*') {\n      return v1;\n    }\n  }\n\n  const range1 = semver.validRange(v1);\n  const range2 = semver.validRange(v2);\n\n  if (!range1 || !range2) {\n    log(`Warning: Could not parse semver for ${packageName}: ${v1}, ${v2}. Using ${v1}`);\n    return v1;\n  }\n\n  const major1 = getMajor(v1);\n  const major2 = getMajor(v2);\n\n  if (major1 === null || major2 === null) {\n    return v1;\n  }\n\n  // Check if major versions are compatible\n  if (major1 !== major2) {\n    error(\n      `Incompatible semver ranges for ${packageName}: ${v1} (major: ${major1}) vs ${v2} (major: ${major2})`,\n    );\n  }\n\n  // Both have same major version, return the higher one\n  // Compare the minimum versions\n  const minVersion1 = semver.minVersion(range1);\n  const minVersion2 = semver.minVersion(range2);\n\n  if (minVersion1 && minVersion2) {\n    if (semver.gt(minVersion1, minVersion2)) {\n      return v1;\n    } else if (semver.gt(minVersion2, minVersion1)) {\n      return v2;\n    }\n  }\n\n  return v1;\n}\n\nfunction mergePeerDependencies(packages: PackageJson[]): Record<string, string> {\n  const result: Record<string, string> = {};\n\n  for (const pkg of packages) {\n    if (!pkg.peerDependencies) {\n      continue;\n    }\n\n    for (const [dep, version] of Object.entries(pkg.peerDependencies)) {\n      if (result[dep]) {\n        // Merge versions\n        result[dep] = mergeSemverVersions(result[dep], version, dep);\n      } else {\n        result[dep] = version;\n      }\n    }\n  }\n\n  // Sort alphabetically\n  return Object.keys(result)\n    .toSorted()\n    .reduce(\n      (sorted, key) => {\n        sorted[key] = result[key];\n        return sorted;\n      },\n      {} as Record<string, string>,\n    );\n}\n\nfunction mergePeerDependenciesMeta(\n  packages: PackageJson[],\n): Record<string, { optional?: boolean }> {\n  const result: Record<string, { optional?: boolean }> = {};\n\n  for (const pkg of packages) {\n    if (!pkg.peerDependenciesMeta) {\n      continue;\n    }\n\n    for (const [dep, meta] of Object.entries(pkg.peerDependenciesMeta)) {\n      if (!result[dep]) {\n        result[dep] = { ...meta };\n      } else {\n        // If any package marks it as optional, keep it optional\n        if (meta.optional) {\n          result[dep].optional = true;\n        }\n      }\n    }\n  }\n\n  // Sort alphabetically\n  return Object.keys(result)\n    .toSorted()\n    .reduce(\n      (sorted, key) => {\n        sorted[key] = result[key];\n        return sorted;\n      },\n      {} as Record<string, { optional?: boolean }>,\n    );\n}\n\nexport function mergePeerDeps() {\n  log('Starting peerDependencies merge...');\n\n  const rootDir = process.cwd();\n\n  // Paths to package.json files\n  const cliPackagePath = join(rootDir, 'packages/cli/package.json');\n  const vitepressPackagePath = join(rootDir, 'packages/cli/node_modules/vitepress/package.json');\n  const tsdownPackagePath = join(rootDir, 'packages/cli/node_modules/tsdown/package.json');\n  const vitestPackagePath = join(rootDir, 'packages/cli/node_modules/vitest-dev/package.json');\n  const rolldownVitePackagePath = join(rootDir, 'vite/packages/vite/package.json');\n\n  // Check if all files exist\n  const packagePaths = [\n    { path: vitepressPackagePath, name: 'vitepress' },\n    { path: tsdownPackagePath, name: 'tsdown' },\n    { path: vitestPackagePath, name: 'vitest' },\n    { path: rolldownVitePackagePath, name: 'vite' },\n  ];\n\n  const packages: PackageJson[] = [];\n\n  for (const { path, name } of packagePaths) {\n    if (!existsSync(path)) {\n      log(`Warning: ${name} package.json not found at ${path}, skipping...`);\n      continue;\n    }\n    const pkg = JSON.parse(readFileSync(path, 'utf-8')) as PackageJson;\n    packages.push(pkg);\n    log(`Loaded ${name} package.json`);\n  }\n\n  if (packages.length === 0) {\n    error('No package.json files found to merge');\n  }\n\n  log(`Merging peerDependencies from ${packages.length} packages...`);\n\n  const mergedPeerDeps = mergePeerDependencies(packages);\n  const mergedPeerDepsMeta = mergePeerDependenciesMeta(packages);\n\n  log(`Merged ${Object.keys(mergedPeerDeps).length} peerDependencies`);\n  log(`Merged ${Object.keys(mergedPeerDepsMeta).length} peerDependenciesMeta entries`);\n\n  // Read CLI package.json\n  const cliPackage = JSON.parse(readFileSync(cliPackagePath, 'utf-8')) as PackageJson;\n\n  // Update with merged dependencies\n  cliPackage.peerDependencies = mergedPeerDeps;\n  cliPackage.peerDependenciesMeta = mergedPeerDepsMeta;\n\n  // Write back to CLI package.json\n  writeFileSync(cliPackagePath, JSON.stringify(cliPackage, null, 2) + '\\n', 'utf-8');\n\n  log('✓ peerDependencies merged successfully!');\n  log('✓ Done!');\n}\n"
  },
  {
    "path": "packages/tools/src/replace-file-content.ts",
    "content": "import { readFileSync, writeFileSync } from 'node:fs';\nimport path from 'node:path';\nimport { parseArgs } from 'node:util';\n\nexport function replaceFileContent() {\n  const { positionals } = parseArgs({\n    allowPositionals: true,\n    args: process.argv.slice(3),\n  });\n\n  const filename = positionals[0];\n  const searchValue = positionals[1];\n  const newValue = positionals[2];\n\n  if (!filename || !searchValue || !newValue) {\n    console.error('Usage: tool replace-file-content <filename> <searchValue> <newValue>');\n    console.error(\n      'Example: tool replace-file-content example.toml \\'version = \"0.0.0\"\\' \\'version = \"0.0.1\"\\'',\n    );\n    process.exit(1);\n  }\n\n  const filepath = path.resolve(filename);\n  const content = readFileSync(filepath, 'utf-8');\n  const newContent = content.replace(searchValue, newValue);\n  writeFileSync(filepath, newContent, 'utf-8');\n}\n"
  },
  {
    "path": "packages/tools/src/snap-test.ts",
    "content": "import { randomUUID } from 'node:crypto';\nimport fs, { readFileSync } from 'node:fs';\nimport fsPromises from 'node:fs/promises';\nimport { open } from 'node:fs/promises';\nimport { cpus, homedir, tmpdir } from 'node:os';\nimport path from 'node:path';\nimport { setTimeout } from 'node:timers/promises';\nimport { debuglog, parseArgs } from 'node:util';\n\nimport { npath } from '@yarnpkg/fslib';\nimport { execute } from '@yarnpkg/shell';\n\nimport { isPassThroughEnv, replaceUnstableOutput } from './utils';\n\nconst debug = debuglog('vite-plus/snap-test');\n\n// Remove comments (starting with ' #') from command strings\n// `@yarnpkg/shell` doesn't parse comments.\n// This doesn't handle all edge cases (such as ' #' in quoted strings), but is good enough for our test cases.\nfunction stripComments(command: string): string {\n  if (command.trim().startsWith('#')) {\n    return '';\n  }\n  const commentStart = command.indexOf(' #');\n  return commentStart === -1 ? command : command.slice(0, commentStart);\n}\n\n/**\n * Run tasks with limited concurrency based on CPU count.\n * @param tasks Array of task functions to execute\n * @param maxConcurrency Maximum number of concurrent tasks (defaults to CPU count)\n */\nasync function runWithConcurrencyLimit(\n  tasks: (() => Promise<void>)[],\n  maxConcurrency = cpus().length,\n): Promise<void> {\n  const executing: Promise<void>[] = [];\n  const errors: Error[] = [];\n\n  for (const task of tasks) {\n    const promise = task()\n      .catch((error) => {\n        errors.push(error);\n        console.error('Task failed:', error);\n      })\n      .finally(() => {\n        // oxlint-disable-next-line typescript/no-floating-promises\n        executing.splice(executing.indexOf(promise), 1);\n      });\n\n    executing.push(promise);\n\n    if (executing.length >= maxConcurrency) {\n      await Promise.race(executing);\n    }\n  }\n\n  await Promise.all(executing);\n\n  if (errors.length > 0) {\n    throw new Error(`${errors.length} test case(s) failed. First error: ${errors[0].message}`);\n  }\n}\n\nfunction expandHome(p: string): string {\n  return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p;\n}\n\nexport async function snapTest() {\n  const { positionals, values } = parseArgs({\n    allowPositionals: true,\n    args: process.argv.slice(3),\n    options: {\n      dir: { type: 'string' },\n      'bin-dir': { type: 'string' },\n    },\n  });\n\n  const filter = positionals[0] ?? ''; // Optional filter to run specific test cases\n\n  // Create a unique temporary directory for testing\n  // On macOS, `tmpdir()` is a symlink. Resolve it so that we can replace the resolved cwd in outputs.\n  // Remove hyphens from UUID to avoid npm's @npmcli/redact treating the path as containing\n  // secrets (it matches UUID patterns like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`).\n  const systemTmpDir = fs.realpathSync(tmpdir());\n  const tempTmpDir = `${systemTmpDir}/vite-plus-test-${randomUUID().replaceAll('-', '')}`;\n  fs.mkdirSync(tempTmpDir, { recursive: true });\n\n  // Clean up stale .node-version and package.json in the system temp directory.\n  // vite-plus walks up the directory tree to resolve Node.js versions, so leftover\n  // files from previous runs can cause tests to pick up unexpected version configs.\n  for (const staleFile of ['.node-version', 'package.json']) {\n    const stalePath = path.join(systemTmpDir, staleFile);\n    if (fs.existsSync(stalePath)) {\n      fs.rmSync(stalePath);\n    }\n  }\n\n  const vitePlusHome = path.join(homedir(), '.vite-plus');\n\n  // Remove .previous-version so command-upgrade-rollback snap test is stable\n  const previousVersionPath = path.join(vitePlusHome, '.previous-version');\n  if (fs.existsSync(previousVersionPath)) {\n    fs.rmSync(previousVersionPath);\n  }\n\n  // Ensure shim mode is \"managed\" so snap tests use vite-plus managed Node.js\n  // instead of the system Node.js (equivalent to running `vp env on`).\n  const configPath = path.join(vitePlusHome, 'config.json');\n  if (fs.existsSync(configPath)) {\n    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));\n    if (config.shimMode && config.shimMode !== 'managed') {\n      delete config.shimMode;\n      fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\\n');\n    }\n  }\n\n  // Make dependencies available in the test cases.\n  // Create a real node_modules directory so we can add the CLI package itself\n  // alongside the symlinked dependencies (needed for `vite-plus/*` imports in\n  // vite.config.ts).\n  const tempNodeModules = path.join(tempTmpDir, 'node_modules');\n  fs.mkdirSync(tempNodeModules);\n  const cliNodeModules = path.resolve('node_modules');\n  for (const entry of fs.readdirSync(cliNodeModules)) {\n    fs.symlinkSync(\n      path.join(cliNodeModules, entry),\n      path.join(tempNodeModules, entry),\n      process.platform === 'win32' ? 'junction' : 'dir',\n    );\n  }\n  // Add the CLI package itself so `vite-plus/*` subpath imports resolve\n  fs.symlinkSync(\n    path.resolve('.'),\n    path.join(tempNodeModules, 'vite-plus'),\n    process.platform === 'win32' ? 'junction' : 'dir',\n  );\n\n  // Clean up the temporary directory on exit\n  process.on('exit', () => fs.rmSync(tempTmpDir, { recursive: true, force: true }));\n\n  const casesDir = path.resolve(values.dir || 'snap-tests');\n\n  const serialTasks: (() => Promise<void>)[] = [];\n  const parallelTasks: (() => Promise<void>)[] = [];\n  const missingStepsJson: string[] = [];\n  for (const caseName of fs.readdirSync(casesDir)) {\n    if (caseName.startsWith('.')) {\n      continue;\n    }\n    const caseDir = path.join(casesDir, caseName);\n    if (!fs.statSync(caseDir).isDirectory()) {\n      continue;\n    }\n    const stepsPath = path.join(caseDir, 'steps.json');\n    if (!fs.existsSync(stepsPath)) {\n      missingStepsJson.push(caseName);\n      continue;\n    }\n    if (caseName.includes(filter)) {\n      const steps: Steps = JSON.parse(readFileSync(stepsPath, 'utf-8'));\n      const task = () => runTestCase(caseName, tempTmpDir, casesDir, values['bin-dir']);\n      if (steps.serial) {\n        serialTasks.push(task);\n      } else {\n        parallelTasks.push(task);\n      }\n    }\n  }\n\n  if (missingStepsJson.length > 0) {\n    throw new Error(\n      `${missingStepsJson.length} test case(s) missing steps.json: ${missingStepsJson.join(', ')}`,\n    );\n  }\n\n  const totalCount = serialTasks.length + parallelTasks.length;\n  if (totalCount > 0) {\n    const cpuCount = cpus().length;\n    console.log(\n      'Running %d test cases (%d serial + %d parallel, concurrency limit %d)',\n      totalCount,\n      serialTasks.length,\n      parallelTasks.length,\n      cpuCount,\n    );\n    await runWithConcurrencyLimit(serialTasks, 1);\n    await runWithConcurrencyLimit(parallelTasks, cpuCount);\n  }\n  process.exit(0); // Ensure exit even if there are pending timed-out steps\n}\n\ninterface Command {\n  command: string;\n  /**\n   * If true, the stdout and stderr output of the command will be ignored.\n   * This is useful for commands that stdout/stderr is unstable.\n   */\n  ignoreOutput?: boolean;\n  /**\n   * The timeout in milliseconds for the command.\n   * If not specified, the default timeout is 50 seconds.\n   */\n  timeout?: number;\n}\n\ninterface Steps {\n  ignoredPlatforms?: string[];\n  env: Record<string, string>;\n  commands: (string | Command)[];\n  /**\n   * Commands to run after the test completes, regardless of success or failure.\n   * Useful for cleanup tasks like killing background processes.\n   * These commands are not included in the snap output.\n   */\n  after?: string[];\n  /**\n   * If true, this test case will run serially before parallel tests.\n   * Use for tests that modify global shared state (e.g., `vp env default`).\n   */\n  serial?: boolean;\n}\n\nasync function runTestCase(name: string, tempTmpDir: string, casesDir: string, binDir?: string) {\n  const steps: Steps = JSON.parse(\n    await fsPromises.readFile(`${casesDir}/${name}/steps.json`, 'utf-8'),\n  );\n  if (steps.ignoredPlatforms !== undefined && steps.ignoredPlatforms.includes(process.platform)) {\n    console.log('%s skipped on platform %s', name, process.platform);\n    return;\n  }\n\n  console.log('%s started', name);\n  const caseTmpDir = `${tempTmpDir}/${name}`;\n  await fsPromises.cp(`${casesDir}/${name}`, caseTmpDir, {\n    recursive: true,\n    errorOnExist: true,\n  });\n\n  const passThroughEnvs = Object.fromEntries(\n    Object.entries(process.env).filter(([key]) => isPassThroughEnv(key)),\n  );\n  const env: Record<string, string> = {\n    ...passThroughEnvs,\n    // Indicate CLI is running in test mode, so that it prints more detailed outputs.\n    // Also disables tips for stable snapshots.\n    VITE_PLUS_CLI_TEST: '1',\n    // Suppress Node.js runtime warnings (e.g. MODULE_TYPELESS_PACKAGE_JSON)\n    // to keep snap outputs stable across Node.js versions.\n    NODE_NO_WARNINGS: '1',\n    NO_COLOR: 'true',\n    // set CI=true make sure snap-tests are stable on GitHub Actions\n    CI: 'true',\n    VITE_PLUS_HOME: path.join(homedir(), '.vite-plus'),\n    // Set git identity so `git commit` works on CI runners without global git config\n    GIT_AUTHOR_NAME: 'Test',\n    GIT_COMMITTER_NAME: 'Test',\n    GIT_AUTHOR_EMAIL: 'vite-plus-test@test.com',\n    GIT_COMMITTER_EMAIL: 'vite-plus-test@test.com',\n    // Skip `vp install` inside `vp migrate` — snap tests don't need real installs\n    VITE_PLUS_SKIP_INSTALL: '1',\n    // make sure npm install global packages to the temporary directory\n    NPM_CONFIG_PREFIX: path.join(tempTmpDir, 'npm-global-lib-for-snap-tests'),\n\n    // A test case can override/unset environment variables above.\n    // For example, VITE_PLUS_CLI_TEST/CI can be unset to test the real-world outputs.\n    ...steps.env,\n  };\n\n  // Unset VITE_PLUS_NODE_VERSION to prevent `vp env use` session overrides\n  // from leaking into snap tests (it passes through via the VITE_* pattern).\n  delete env['VITE_PLUS_NODE_VERSION'];\n\n  // Unset VITE_PLUS_TOOL_RECURSION to prevent the shim recursion guard from\n  // leaking into snap tests. When `pnpm` runs the test via the `vp` shim, vp\n  // sets this marker before exec. Without clearing it, every npm/node command\n  // in the test would bypass the managed shim and fall through to the system binary.\n  delete env['VITE_PLUS_TOOL_RECURSION'];\n\n  // Sometimes on Windows, the PATH variable is named 'Path'\n  if ('Path' in env && !('PATH' in env)) {\n    env['PATH'] = env['Path'];\n    delete env['Path'];\n  }\n  // The node shim prepends ~/.vite-plus/js_runtime/node/VERSION/bin/ to PATH,\n  // which leaks into this process. Strip internal vite-plus paths so the test\n  // environment simulates a clean user PATH (only the shim bin dir + system paths).\n  const vitePlusJsRuntime = path.join(env['VITE_PLUS_HOME'], 'js_runtime');\n  env['PATH'] = [\n    // Extend PATH to include the package's bin directory\n    // --bin-dir overrides the default for cases like global CLI tests\n    // where vp should resolve to the Rust binary instead of the Node.js script\n    path.resolve(expandHome(binDir || 'bin')),\n    ...env['PATH'].split(path.delimiter).filter((p) => !p.startsWith(vitePlusJsRuntime)),\n  ].join(path.delimiter);\n\n  const newSnap: string[] = [];\n\n  const startTime = Date.now();\n  const cwd = npath.toPortablePath(caseTmpDir);\n\n  try {\n    for (const command of steps.commands) {\n      const cmd = typeof command === 'string' ? { command } : command;\n      debug('running command: %o, cwd: %s, env: %o', cmd, caseTmpDir, env);\n\n      // While `@yarnpkg/shell` supports capturing output via in-memory `Writable` streams,\n      // it seems not to have stable ordering of stdout/stderr chunks.\n      // To ensure stable ordering, we redirect outputs to a file instead.\n      const outputStreamPath = path.join(caseTmpDir, 'output.log');\n      const outputStream = await open(outputStreamPath, 'w');\n\n      const exitCode = await Promise.race([\n        execute(stripComments(cmd.command), [], {\n          env,\n          cwd,\n          stdin: null,\n          // Declared to be `Writable` but `FileHandle` works too.\n          // @ts-expect-error\n          stderr: outputStream,\n          // @ts-expect-error\n          stdout: outputStream,\n          glob: {\n            // Disable glob expansion. Pass args like '--filter=*' as-is.\n            isGlobPattern: () => false,\n            match: async () => [],\n          },\n        }),\n        setTimeout(cmd.timeout ?? 50 * 1000),\n      ]);\n\n      await outputStream.close();\n\n      let output = readFileSync(outputStreamPath, 'utf-8');\n\n      let commandLine = `> ${cmd.command}`;\n      if (exitCode !== 0) {\n        commandLine = (exitCode === undefined ? '[timeout]' : `[${exitCode}]`) + commandLine;\n      } else {\n        // only allow ignore output if the command is successful\n        if (cmd.ignoreOutput) {\n          output = '';\n        }\n      }\n      newSnap.push(commandLine);\n      if (output.length > 0) {\n        newSnap.push(replaceUnstableOutput(output, caseTmpDir));\n      }\n      if (exitCode === undefined) {\n        break; // Stop executing further commands on timeout\n      }\n    }\n  } finally {\n    // Run after commands for cleanup, regardless of success or failure\n    if (steps.after) {\n      for (const afterCmd of steps.after) {\n        debug('running after command: %s, cwd: %s', afterCmd, caseTmpDir);\n        try {\n          await execute(stripComments(afterCmd), [], {\n            env,\n            cwd,\n            stdin: null,\n          });\n        } catch (error) {\n          debug('after command failed: %s, error: %o', afterCmd, error);\n        }\n      }\n    }\n  }\n\n  const newSnapContent = newSnap.join('\\n');\n\n  await fsPromises.writeFile(`${casesDir}/${name}/snap.txt`, newSnapContent);\n  console.log('%s finished in %dms', name, Date.now() - startTime);\n}\n"
  },
  {
    "path": "packages/tools/src/sync-remote-deps.ts",
    "content": "import { execSync, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { parseArgs } from 'node:util';\n\nimport upstreamVersions from '../.upstream-versions.json' with { type: 'json' };\n\ninterface PnpmWorkspace {\n  packages?: string[];\n  catalog?: Record<string, string>;\n  catalogMode?: string;\n  minimumReleaseAge?: number;\n  minimumReleaseAgeExclude?: string[];\n  patchedDependencies?: Record<string, string>;\n  peerDependencyRules?: {\n    allowedVersions?: Record<string, string>;\n  };\n  packageExtensions?: Record<string, unknown>;\n  overrides?: Record<string, string>;\n  ignoreScripts?: boolean;\n  [key: string]: unknown;\n}\n\ninterface PackageJson {\n  name?: string;\n  version?: string;\n  exports?: Record<string, unknown>;\n  [key: string]: unknown;\n}\n\ntype ExportValue = string | { [condition: string]: string | ExportValue } | null;\n\nconst ROLLDOWN_DIR = 'rolldown';\nconst VITE_DIR = 'vite';\nconst CORE_PACKAGE_PATH = 'packages/core';\n\nfunction log(message: string) {\n  console.log(`[sync-rolldown] ${message}`);\n}\n\nfunction error(message: string): never {\n  console.error(`[sync-rolldown] ERROR: ${message}`);\n  process.exit(1);\n}\n\nconst getMajor = (range: string): number | null => {\n  const match = range.match(/(\\d+)\\./);\n  return match ? parseInt(match[1], 10) : null;\n};\n\nfunction execCommand(command: string, cwd?: string): string {\n  try {\n    return execSync(command, {\n      cwd,\n      encoding: 'utf-8',\n      stdio: 'pipe',\n    }).trim();\n  } catch (error) {\n    throw new Error(\n      `Failed to execute: ${command}\\n${error instanceof Error ? error.message : String(error)}`,\n      { cause: error },\n    );\n  }\n}\n\nfunction cloneOrResetRepo(repoUrl: string, dir: string, branch: string = 'main', hash?: string) {\n  log(`Processing ${dir}...`);\n\n  if (existsSync(dir)) {\n    log(`${dir} exists, checking git status...`);\n    try {\n      // Check if it's a valid git repo\n      const result = spawnSync('git', ['rev-parse', '--git-dir'], {\n        cwd: dir,\n        encoding: 'utf-8',\n      });\n\n      if (result.status !== 0) {\n        log(`${dir} is not a valid git repo, removing and re-cloning...`);\n        rmSync(dir, { recursive: true, force: true });\n        cloneRepo(repoUrl, dir, branch, hash);\n        return;\n      }\n\n      // Check remote URL\n      const remoteUrl = execCommand('git remote get-url origin', dir);\n      if (remoteUrl !== repoUrl) {\n        log(`${dir} has wrong remote (${remoteUrl} vs ${repoUrl}), removing and re-cloning...`);\n        rmSync(dir, { recursive: true, force: true });\n        cloneRepo(repoUrl, dir, branch, hash);\n        return;\n      }\n\n      // Fetch latest commits and tags\n      execCommand('git fetch origin --tags', dir);\n\n      if (hash) {\n        // Reset to specific hash\n        log(`Resetting ${dir} to pinned hash ${hash.substring(0, 8)}...`);\n        execCommand(`git checkout ${branch}`, dir);\n        execCommand(`git reset --hard ${hash}`, dir);\n        log(`${dir} reset to ${hash.substring(0, 8)}`);\n      } else {\n        // Reset to latest - check if branch is a tag or a branch\n        log(`Resetting ${dir} to latest ${branch}...`);\n        const isTag =\n          spawnSync('git', ['tag', '-l', branch], {\n            cwd: dir,\n            encoding: 'utf-8',\n          }).stdout.trim() === branch;\n\n        if (isTag) {\n          // For tags, just checkout the tag directly\n          execCommand(`git checkout ${branch}`, dir);\n          log(`${dir} reset to tag ${branch}`);\n        } else {\n          // For branches, reset to origin/branch\n          execCommand(`git checkout ${branch}`, dir);\n          execCommand(`git reset --hard origin/${branch}`, dir);\n          log(`${dir} reset to latest ${branch}`);\n        }\n      }\n    } catch (error) {\n      log(\n        `Failed to reset ${dir} (${error instanceof Error ? error.message : String(error)}), removing and re-cloning...`,\n      );\n      rmSync(dir, { recursive: true, force: true });\n      cloneRepo(repoUrl, dir, branch, hash);\n    }\n  } else {\n    cloneRepo(repoUrl, dir, branch, hash);\n  }\n}\n\nfunction cloneRepo(repoUrl: string, dir: string, branch: string, hash?: string) {\n  log(`Cloning ${repoUrl} (${branch}) into ${dir}...`);\n  execCommand(`git clone --branch ${branch} ${repoUrl} ${dir}`);\n  if (hash) {\n    log(`Checking out pinned hash ${hash.substring(0, 8)}...`);\n    execCommand(`git reset --hard ${hash}`, dir);\n    log(`${dir} cloned and reset to ${hash.substring(0, 8)}`);\n  } else {\n    log(`${dir} cloned successfully`);\n  }\n}\n\nfunction transformRolldownExport(exportPath: string, exportValue: unknown): [string, ExportValue] {\n  // Skip package.json\n  if (exportPath === './package.json') {\n    return ['', null];\n  }\n\n  // Transform export path: . -> ./rolldown, ./foo -> ./rolldown/foo\n  const newExportPath = exportPath === '.' ? './rolldown' : `./rolldown${exportPath.slice(1)}`;\n\n  // Transform export value\n  const transformValue = (value: unknown): ExportValue => {\n    if (typeof value === 'string') {\n      // Skip 'dev' condition paths that point to src\n      if (value.startsWith('./src/')) {\n        return null;\n      }\n      // Transform dist paths\n      return value.replace(/^\\.\\/dist\\//, './dist/rolldown/');\n    }\n\n    if (value && typeof value === 'object') {\n      const result: Record<string, unknown> = {};\n      for (const [key, val] of Object.entries(value)) {\n        // Skip 'dev' condition\n        if (key === 'dev') {\n          continue;\n        }\n\n        const transformed = transformValue(val);\n        if (transformed !== null) {\n          result[key] = transformed;\n        }\n      }\n      return Object.keys(result).length > 0 ? (result as ExportValue) : null;\n    }\n\n    return value as ExportValue;\n  };\n\n  const newValue = transformValue(exportValue);\n\n  // Handle string values or add types if missing\n  if (typeof newValue === 'string') {\n    // Convert string to object with default and types\n    if (newValue.endsWith('.mjs')) {\n      return [\n        newExportPath,\n        {\n          default: newValue,\n          types: newValue.replace(/\\.mjs$/, '.d.mts'),\n        },\n      ];\n    } else if (newValue.endsWith('.js')) {\n      return [\n        newExportPath,\n        {\n          default: newValue,\n          types: newValue.replace(/\\.js$/, '.d.ts'),\n        },\n      ];\n    }\n    return [newExportPath, newValue];\n  }\n\n  if (newValue && typeof newValue === 'object') {\n    const importPath = ('import' in newValue ? newValue.import : newValue.default) as\n      | string\n      | undefined;\n    if (importPath && !('types' in newValue)) {\n      if (importPath.endsWith('.mjs')) {\n        newValue.types = importPath.replace(/\\.mjs$/, '.d.mts');\n      } else if (importPath.endsWith('.js')) {\n        newValue.types = importPath.replace(/\\.js$/, '.d.ts');\n      }\n    }\n  }\n\n  return [newExportPath, newValue];\n}\n\nfunction transformPluginutilsExport(\n  exportPath: string,\n  exportValue: unknown,\n): [string, ExportValue] {\n  // Skip package.json\n  if (exportPath === './package.json') {\n    return ['', null];\n  }\n\n  // Transform . -> ./rolldown/pluginutils\n  const newExportPath =\n    exportPath === '.' ? './rolldown/pluginutils' : `./rolldown/pluginutils${exportPath.slice(1)}`;\n\n  // Transform paths\n  const transformValue = (value: unknown): ExportValue => {\n    if (typeof value === 'string') {\n      if (value.startsWith('./src/')) {\n        return null;\n      }\n      return value.replace(/^\\.\\/dist\\//, './dist/pluginutils/');\n    }\n\n    if (value && typeof value === 'object') {\n      const result: Record<string, unknown> = {};\n      for (const [key, val] of Object.entries(value)) {\n        if (key === 'dev') {\n          continue;\n        }\n        const transformed = transformValue(val);\n        if (transformed !== null) {\n          result[key] = transformed;\n        }\n      }\n      return Object.keys(result).length > 0 ? (result as ExportValue) : null;\n    }\n\n    return value as ExportValue;\n  };\n\n  const newValue = transformValue(exportValue);\n\n  // Handle string values or add types if missing\n  if (typeof newValue === 'string') {\n    // Convert string to object with default and types\n    if (newValue.endsWith('.js')) {\n      return [\n        newExportPath,\n        {\n          default: newValue,\n          types: newValue.replace(/\\.js$/, '.d.ts'),\n        },\n      ];\n    }\n    return [newExportPath, newValue];\n  }\n\n  if (newValue && typeof newValue === 'object') {\n    const importPath = ('import' in newValue ? newValue.import : newValue.default) as\n      | string\n      | undefined;\n    if (importPath && !('types' in newValue)) {\n      if (importPath.endsWith('.js')) {\n        newValue.types = importPath.replace(/\\.js$/, '.d.ts');\n      }\n    }\n  }\n\n  return [newExportPath, newValue];\n}\n\nfunction transformViteExport(exportPath: string, exportValue: unknown): [string, ExportValue] {\n  // Skip package.json\n  if (exportPath === './package.json') {\n    return ['', null];\n  }\n\n  // Keys remain unchanged\n  const newExportPath = exportPath;\n\n  // Transform paths in values\n  const transformValue = (value: unknown): ExportValue => {\n    if (typeof value === 'string') {\n      // Transform types paths\n      if (value.startsWith('./types/')) {\n        return value.replace(/^\\.\\/types\\//, './dist/vite/types/');\n      } else if (value.startsWith('./dist')) {\n        return value.replace(/^\\.\\/dist\\//, './dist/vite/');\n      }\n\n      return `./dist/vite/${value.slice(2)}`;\n    }\n\n    if (value && typeof value === 'object') {\n      const result: Record<string, unknown> = {};\n      for (const [key, val] of Object.entries(value)) {\n        const transformed = transformValue(val);\n        if (transformed !== null) {\n          result[key] = transformed;\n        }\n      }\n      return Object.keys(result).length > 0 ? (result as ExportValue) : null;\n    }\n\n    return value as ExportValue;\n  };\n\n  const newValue = transformValue(exportValue);\n\n  if (newValue && typeof newValue === 'object') {\n    const importPath = ('import' in newValue ? newValue.import : newValue.default) as\n      | string\n      | undefined;\n    if (importPath && !('types' in newValue) && typeof importPath === 'string') {\n      if (importPath.endsWith('.js')) {\n        newValue.types = importPath.replace(/\\.js$/, '.d.ts');\n      }\n    }\n  }\n\n  return [newExportPath, newValue];\n}\n\nfunction mergePackageExports(\n  corePkg: PackageJson,\n  rolldownPkg: PackageJson,\n  rolldownVitePkg: PackageJson,\n  pluginutilsPkg: PackageJson,\n): Record<string, unknown> {\n  const result: Record<string, unknown> = {};\n\n  if (corePkg.exports) {\n    for (const [path, value] of Object.entries(corePkg.exports)) {\n      result[path] = value;\n    }\n  }\n\n  // Add rolldown exports\n  if (rolldownPkg.exports) {\n    for (const [path, value] of Object.entries(rolldownPkg.exports)) {\n      const [newPath, newValue] = transformRolldownExport(path, value);\n      if (newPath && newValue !== null) {\n        result[newPath] = newValue;\n      }\n    }\n  }\n\n  // Add pluginutils exports\n  if (pluginutilsPkg.exports) {\n    for (const [path, value] of Object.entries(pluginutilsPkg.exports)) {\n      const [newPath, newValue] = transformPluginutilsExport(path, value);\n      if (newPath && newValue !== null) {\n        result[newPath] = newValue;\n      }\n    }\n  }\n\n  // Add vite exports\n  if (rolldownVitePkg.exports) {\n    for (const [path, value] of Object.entries(rolldownVitePkg.exports)) {\n      const [newPath, newValue] = transformViteExport(path, value);\n      if (newPath && newValue !== null) {\n        result[newPath] = newValue;\n      }\n    }\n  }\n\n  // Sort exports by key\n  return Object.keys(result)\n    .toSorted()\n    .reduce(\n      (sorted, key) => {\n        sorted[key] = result[key];\n        return sorted;\n      },\n      {} as Record<string, unknown>,\n    );\n}\n\n// Oxc-related packages that should use the higher version on conflict\nconst OXC_PACKAGE_PREFIXES = [\n  '@oxc-project/',\n  '@oxlint/',\n  '@oxc-minify/',\n  '@oxc-parser/',\n  '@oxc-resolver/',\n  '@oxc-transform/',\n  '@oxfmt/',\n  '@oxlint-tsgolint/',\n];\nconst OXC_PACKAGES = new Set([\n  'oxc-minify',\n  'oxc-parser',\n  'oxc-transform',\n  'oxfmt',\n  'oxlint',\n  'oxlint-tsgolint',\n]);\nconst VITEST_DEPS = new Set(['tinybench']);\n\n// These packages should always use the highest version\nfunction syncedPackages(packageName: string): boolean {\n  if (OXC_PACKAGES.has(packageName) || VITEST_DEPS.has(packageName)) {\n    return true;\n  }\n  return OXC_PACKAGE_PREFIXES.some((prefix) => packageName.startsWith(prefix));\n}\n\nfunction mergeSemverVersions(\n  v1: string,\n  v2: string,\n  packageName: string,\n  semver: typeof import('semver'),\n): string {\n  // Handle special cases\n  if (v1 === v2) {\n    return v1;\n  }\n\n  // Handle exact version specifiers (=)\n  const isExact1 = v1.startsWith('=');\n  const isExact2 = v2.startsWith('=');\n  if (isExact1 || isExact2) {\n    if (isExact1 && isExact2 && v1 !== v2) {\n      // For oxc-related packages, use the higher version\n      if (syncedPackages(packageName)) {\n        const ver1 = v1.slice(1); // Remove '=' prefix\n        const ver2 = v2.slice(1);\n        if (semver.valid(ver1) && semver.valid(ver2)) {\n          const higher = semver.gt(ver1, ver2) ? v1 : v2;\n          log(`Resolving ${packageName} version conflict: ${v1} vs ${v2} -> ${higher}`);\n          return higher;\n        }\n      }\n      error(`Incompatible exact versions for ${packageName}: ${v1} vs ${v2}`);\n    }\n    return isExact1 ? v1 : v2;\n  }\n\n  if (v1.startsWith('npm:') || v2.startsWith('npm:')) {\n    if (!v1.startsWith('npm:')) {\n      return v1;\n    }\n    if (!v2.startsWith('npm:')) {\n      return v2;\n    }\n    return v1;\n  }\n\n  const range1 = semver.validRange(v1);\n  const range2 = semver.validRange(v2);\n\n  if (!range1 || !range2) {\n    log(`Warning: Could not parse semver for ${packageName}: ${v1}, ${v2}. Using ${v1}`);\n    return v1;\n  }\n\n  const major1 = getMajor(v1);\n  const major2 = getMajor(v2);\n\n  if (major1 === null || major2 === null) {\n    return v1;\n  }\n\n  // Check if major versions are compatible\n  if (major1 !== major2) {\n    // For synced packages, use the higher major version\n    if (syncedPackages(packageName)) {\n      const higher = major1 > major2 ? v1 : v2;\n      log(`Resolving ${packageName} major version conflict: ${v1} vs ${v2} -> ${higher}`);\n      return higher;\n    }\n    error(\n      `Incompatible semver ranges for ${packageName}: ${v1} (major: ${major1}) vs ${v2} (major: ${major2})`,\n    );\n  }\n\n  // Both have same major version, return the higher one\n  // Compare the minimum versions\n  const minVersion1 = semver.minVersion(range1);\n  const minVersion2 = semver.minVersion(range2);\n\n  if (minVersion1 && minVersion2) {\n    if (semver.gt(minVersion1, minVersion2)) {\n      return v1;\n    } else if (semver.gt(minVersion2, minVersion1)) {\n      return v2;\n    }\n  }\n\n  return v1;\n}\n\nfunction mergePnpmWorkspaces(\n  main: PnpmWorkspace,\n  rolldown: PnpmWorkspace,\n  rolldownVite: PnpmWorkspace,\n  semver: typeof import('semver'),\n): PnpmWorkspace {\n  const result: PnpmWorkspace = { ...main };\n\n  // Merge packages array\n  const packagesSet = new Set(main.packages || []);\n  // Add rolldown packages\n  packagesSet.add(ROLLDOWN_DIR);\n  packagesSet.add(`${ROLLDOWN_DIR}/packages/*`);\n  // Add vite packages\n  packagesSet.add(VITE_DIR);\n  packagesSet.add(`${VITE_DIR}/packages/*`);\n  result.packages = Array.from(packagesSet);\n\n  // Merge catalog\n  const catalog: Record<string, string> = { ...main.catalog };\n\n  // Add all entries from rolldown catalog\n  for (const [pkg, version] of Object.entries(rolldown.catalog || {})) {\n    if (catalog[pkg]) {\n      // Merge versions\n      catalog[pkg] = mergeSemverVersions(catalog[pkg], version, pkg, semver);\n    } else {\n      catalog[pkg] = version;\n    }\n  }\n\n  // Add all entries from vite catalog (if it has one)\n  for (const [pkg, version] of Object.entries(rolldownVite.catalog || {})) {\n    if (catalog[pkg]) {\n      // Merge versions\n      catalog[pkg] = mergeSemverVersions(catalog[pkg], version, pkg, semver);\n    } else {\n      catalog[pkg] = version;\n    }\n  }\n\n  // Remove vite from catalog\n  delete catalog.vite;\n\n  // Sort catalog keys alphabetically\n  result.catalog = Object.keys(catalog)\n    .toSorted()\n    .reduce(\n      (sorted, key) => {\n        sorted[key] = catalog[key];\n        return sorted;\n      },\n      {} as Record<string, string>,\n    );\n\n  // Merge minimumReleaseAgeExclude\n  const excludeSet = new Set(main.minimumReleaseAgeExclude || []);\n\n  (rolldown.minimumReleaseAgeExclude || []).forEach((item) => excludeSet.add(item));\n  (rolldownVite.minimumReleaseAgeExclude || []).forEach((item) => excludeSet.add(item));\n  result.minimumReleaseAgeExclude = Array.from(excludeSet);\n\n  // Copy patchedDependencies from vite (with path prefix)\n  if (rolldownVite.patchedDependencies) {\n    result.patchedDependencies = {};\n    for (const [dep, patchPath] of Object.entries(rolldownVite.patchedDependencies)) {\n      // Prepend vite directory to patch paths\n      result.patchedDependencies[dep] = patchPath.startsWith('./')\n        ? `./${VITE_DIR}/${patchPath.slice(2)}`\n        : `${VITE_DIR}/${patchPath}`;\n    }\n  }\n\n  // Merge peerDependencyRules\n  if (rolldownVite.peerDependencyRules) {\n    result.peerDependencyRules = {\n      ...main.peerDependencyRules,\n      allowedVersions: {\n        ...main.peerDependencyRules?.allowedVersions,\n        ...rolldownVite.peerDependencyRules.allowedVersions,\n      },\n    };\n    // Add rolldown to allowed versions\n    if (result.peerDependencyRules.allowedVersions) {\n      result.peerDependencyRules.allowedVersions.rolldown = '*';\n    }\n  }\n\n  // Copy packageExtensions from vite\n  if (rolldownVite.packageExtensions) {\n    result.packageExtensions = {\n      ...main.packageExtensions,\n      ...rolldownVite.packageExtensions,\n    };\n  }\n\n  // Set ignoreScripts\n  result.ignoreScripts = true;\n\n  return result;\n}\n\nexport async function syncRemote() {\n  const { values } = parseArgs({\n    options: {\n      clean: {\n        type: 'boolean',\n      },\n      'update-hashes': {\n        type: 'boolean',\n      },\n    },\n    args: process.argv.slice(3),\n  });\n\n  log('Starting rolldown/vite sync...');\n\n  // Get the root directory (assuming script is run from root)\n  const rootDir = process.cwd();\n\n  if (values.clean) {\n    log('Cleaning existing repositories...');\n    if (existsSync(join(rootDir, ROLLDOWN_DIR))) {\n      rmSync(join(rootDir, ROLLDOWN_DIR), { recursive: true, force: true });\n      log(`Removed ${ROLLDOWN_DIR}`);\n    }\n    if (existsSync(join(rootDir, VITE_DIR))) {\n      rmSync(join(rootDir, VITE_DIR), {\n        recursive: true,\n        force: true,\n      });\n      log(`Removed ${VITE_DIR}`);\n    }\n    // Clean up legacy 'rolldown-vite' directory (renamed to 'vite')\n    const legacyViteDir = join(rootDir, 'rolldown-vite');\n    if (existsSync(legacyViteDir)) {\n      rmSync(legacyViteDir, { recursive: true, force: true });\n      log('Removed legacy rolldown-vite directory');\n    }\n  }\n\n  // Clone or reset repos\n  cloneOrResetRepo(\n    upstreamVersions.rolldown.repo,\n    join(rootDir, ROLLDOWN_DIR),\n    upstreamVersions.rolldown.branch,\n    upstreamVersions.rolldown.hash,\n  );\n  cloneOrResetRepo(\n    upstreamVersions['vite'].repo,\n    join(rootDir, VITE_DIR),\n    upstreamVersions['vite'].branch,\n    upstreamVersions['vite'].hash,\n  );\n\n  // Dynamically import dependencies after git clone\n  let parseYaml: typeof import('yaml').parse;\n  let stringifyYaml: typeof import('yaml').stringify;\n  let semver: typeof import('semver');\n\n  try {\n    const yaml = await import('yaml');\n    parseYaml = yaml.parse;\n    stringifyYaml = yaml.stringify;\n    semver = await import('semver');\n  } catch {\n    log('Dependencies not found, running pnpm install...');\n    execCommand('pnpm install --no-frozen-lockfile', rootDir);\n    log('Retrying imports...');\n    const yaml = await import('yaml');\n    parseYaml = yaml.parse;\n    stringifyYaml = yaml.stringify;\n    semver = await import('semver');\n  }\n\n  log('Reading pnpm-workspace.yaml files...');\n\n  // Read main pnpm-workspace.yaml\n  const mainWorkspacePath = join(rootDir, 'pnpm-workspace.yaml');\n  const mainWorkspace = parseYaml(readFileSync(mainWorkspacePath, 'utf-8')) as PnpmWorkspace;\n\n  // Read rolldown pnpm-workspace.yaml\n  const rolldownWorkspacePath = join(rootDir, ROLLDOWN_DIR, 'pnpm-workspace.yaml');\n  const rolldownWorkspace = parseYaml(\n    readFileSync(rolldownWorkspacePath, 'utf-8'),\n  ) as PnpmWorkspace;\n\n  // Read vite pnpm-workspace.yaml\n  const rolldownViteWorkspacePath = join(rootDir, VITE_DIR, 'pnpm-workspace.yaml');\n  const rolldownViteWorkspace = parseYaml(\n    readFileSync(rolldownViteWorkspacePath, 'utf-8'),\n  ) as PnpmWorkspace;\n\n  log('Merging pnpm-workspace.yaml files...');\n\n  const mergedWorkspace = mergePnpmWorkspaces(\n    mainWorkspace,\n    rolldownWorkspace,\n    rolldownViteWorkspace,\n    semver,\n  );\n\n  // Write the merged workspace back\n  const yamlContent = stringifyYaml(mergedWorkspace, {\n    lineWidth: -1,\n  });\n\n  writeFileSync(mainWorkspacePath, yamlContent, 'utf-8');\n\n  log('✓ pnpm-workspace.yaml updated successfully!');\n\n  execCommand('pnpm install --no-frozen-lockfile', rootDir);\n\n  // Merge package.json exports\n  log('Merging package.json exports...');\n\n  const corePackagePath = join(rootDir, CORE_PACKAGE_PATH, 'package.json');\n  const rolldownPackagePath = join(rootDir, ROLLDOWN_DIR, 'packages', 'rolldown', 'package.json');\n  const rolldownVitePackagePath = join(rootDir, VITE_DIR, 'packages', 'vite', 'package.json');\n  const pluginutilsPackagePath = join(\n    rootDir,\n    ROLLDOWN_DIR,\n    'packages',\n    'pluginutils',\n    'package.json',\n  );\n\n  const corePackage = JSON.parse(readFileSync(corePackagePath, 'utf-8')) as PackageJson;\n  const rolldownPackage = JSON.parse(readFileSync(rolldownPackagePath, 'utf-8')) as PackageJson;\n  const rolldownVitePackage = JSON.parse(\n    readFileSync(rolldownVitePackagePath, 'utf-8'),\n  ) as PackageJson;\n  const pluginutilsPackage = JSON.parse(\n    readFileSync(pluginutilsPackagePath, 'utf-8'),\n  ) as PackageJson;\n\n  const mergedExports = mergePackageExports(\n    corePackage,\n    rolldownPackage,\n    rolldownVitePackage,\n    pluginutilsPackage,\n  );\n\n  // additional tsdown exports (vp pack)\n  mergedExports['./pack'] = {\n    default: './dist/tsdown/index.js',\n    types: './dist/tsdown/index-types.d.ts',\n  };\n\n  // Update CLI package.json with merged exports\n  corePackage.exports = mergedExports;\n\n  writeFileSync(corePackagePath, JSON.stringify(corePackage, null, 2) + '\\n', 'utf-8');\n\n  log('✓ package.json exports updated successfully!');\n\n  // Apply Vite+ branding patches to vite source\n  const { brandVite } = await import('./brand-vite.ts');\n  brandVite(rootDir);\n\n  log('✓ Done!');\n}\n"
  },
  {
    "path": "packages/tools/src/utils.ts",
    "content": "import { homedir } from 'node:os';\nimport path from 'node:path';\n\nimport { Minimatch } from 'minimatch';\n\nconst ANSI_ESCAPE_REGEX = new RegExp(\n  `${String.fromCharCode(27)}(?:[@-Z\\\\\\\\-_]|\\\\[[0-?]*[ -/]*[@-~])`,\n  'g',\n);\n\nexport function replaceUnstableOutput(output: string, cwd?: string) {\n  // Normalize line endings and strip ANSI escapes so snapshots are stable\n  // across CI platforms and terminal capabilities.\n  output = output.replaceAll(ANSI_ESCAPE_REGEX, '').replaceAll(/\\r\\n/g, '\\n').replaceAll(/\\r/g, '');\n\n  if (cwd) {\n    // On Windows, cwd may have mixed separators (from template literals like `${tmp}/name`)\n    // while output uses all-backslash paths (from path.resolve()). Replace the all-backslash\n    // form of each path token, with trailing separator first so the separator after the\n    // placeholder is normalized to forward slash.\n    const replacePathToken = (rawPath: string, placeholder: string) => {\n      if (process.platform === 'win32') {\n        const backslash = rawPath.replaceAll('/', '\\\\');\n        output = output.replaceAll(backslash + '\\\\', placeholder + '/');\n        output = output.replaceAll(backslash, placeholder);\n      }\n      output = output.replaceAll(rawPath, placeholder);\n    };\n    replacePathToken(cwd, '<cwd>');\n    const parent = path.dirname(cwd);\n    if (parent !== '/') {\n      replacePathToken(parent, '<cwd>/..');\n    }\n  }\n  // On Windows, normalize path separators in file paths for consistent snapshots.\n  // Only replace backslashes that look like path separators (preceded/followed by valid path chars).\n  // This avoids breaking ASCII art or escape sequences.\n  if (process.platform === 'win32') {\n    // Replace backslashes in patterns like: word\\word, ./path\\to, src\\file.ts\n    // Pattern: backslash between alphanumeric/dot/underscore/hyphen chars\n    output = output.replaceAll(/([a-zA-Z0-9._-])\\\\([a-zA-Z0-9._-])/g, '$1/$2');\n  }\n  return (\n    output\n      // semver version\n      // e.g.: ` v1.0.0` -> ` <semver>`\n      // e.g.: `/1.0.0` -> `/<semver>`\n      .replaceAll(/([@/\\s]v?)\\d+\\.\\d+\\.\\d+(?:-.*)?/g, '$1<semver>')\n      // vite build banner can appear on some environments/runtimes:\n      // vite v<semver>\n      // transforming...✓ ...\n      // Keep snapshots stable by stripping the standalone banner line.\n      .replaceAll(/(?:^|\\n)vite v<semver>\\n(?=transforming\\.\\.\\.)/g, '\\n')\n      // vite-plus hash version\n      // e.g.: `vite-plus\": \"^0.0.0-aa9f90fe23216b8ad85b0ba4fc1bccb0614afaf0\"` -> `vite-plus\": \"^0.0.0-<hash>`\n      .replaceAll(/0\\.0\\.0-\\w{40}/g, '0.0.0-<hash>')\n      // date (YYYY-MM-DD HH:MM:SS)\n      .replaceAll(/\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}/g, '<date>')\n      // date only (YYYY-MM-DD)\n      .replaceAll(/\\d{4}-\\d{2}-\\d{2}/g, '<date>')\n      // time only (HH:MM:SS)\n      .replaceAll(/\\d{2}:\\d{2}:\\d{2}/g, '<date>')\n      // duration\n      .replaceAll(/\\d+(?:\\.\\d+)?(?:s|ms|µs|ns)/g, '<variable>ms')\n      // parenthesized thread counts in CLI summaries\n      .replaceAll(/, \\d+ threads\\)/g, ', <variable> threads)')\n      // oxlint\n      .replaceAll(/with \\d+ rules/g, 'with <variable> rules')\n      .replaceAll(/using \\d+ threads/g, 'using <variable> threads')\n      // pnpm\n      .replaceAll(/Packages: \\+\\d+/g, 'Packages: +<variable>')\n      // only keep done\n      .replaceAll(\n        /Progress: resolved \\d+, reused \\d+, downloaded \\d+, added \\d+, done/g,\n        'Progress: resolved <variable>, reused <variable>, downloaded <variable>, added <variable>, done',\n      )\n      // ignore pnpm progress\n      .replaceAll(/Progress: resolved \\d+, reused \\d+, downloaded \\d+, added \\d+\\n/g, '')\n      // ignore pnpm warn\n      .replaceAll(/ ?WARN\\s+Skip\\s+adding .+?\\n/g, '')\n      .replaceAll(/ ?WARN\\s+Request\\s+took .+?\\n/g, '')\n      .replaceAll(/Scope: all \\d+ workspace projects/g, 'Scope: all <variable> workspace projects')\n      .replaceAll(/\\++\\n/g, '+<repeat>\\n')\n      // ignore pnpm registry request error warning log\n      .replaceAll(/ ?WARN\\s+GET\\s+https:\\/\\/registry\\..+?\\n/g, '')\n      // ignore yarn YN0013, because it's unstable output, only exists on CI environment\n      // ➤ YN0013: │ A package was added to the project (+ 0.7 KiB).\n      .replaceAll(/➤ YN0013:[^\\n]+\\n/g, '')\n      // ignore yarn `YN0000: └ Completed <duration>`, it's unstable output\n      // ➤ YN0000: └ Completed in <variable>ms <variable>ms\n      // ➤ YN0000: └ Completed in <variable>ms\n      // =>\n      // ➤ YN0000: └ Completed\n      .replaceAll(\n        /➤ YN0000: └ Completed.* <variable>(s|ms|µs)( <variable>(s|ms|µs))?\\n/g,\n        '➤ YN0000: └ Completed\\n',\n      )\n      // ignore npm warn\n      // npm warn Unknown env config \"recursive\". This will stop working in the next major version of npm\n      .replaceAll(/npm warn Unknown env config .+?\\n/g, '')\n      // WARN  Issue while reading \"/path/to/.npmrc\". Failed to replace env in config: ${NPM_AUTH_TOKEN}\n      .replaceAll(/WARN\\s+Issue\\s+while\\s+reading .+?\\n/g, '')\n      // ignore npm audited packages log\n      // \"removed 1 package, and audited 3 packages in 700ms\" => \"removed <variable> package in <variable>ms\"\n      // \"up to date, audited 4 packages in 11ms\" => \"up to date in <variable>ms\"\n      // \"added 3 packages, and audited 4 packages in 100ms\" => \"added 3 packages in <variable>ms\"\n      // \"\\nfound 0 vulnerabilities\\n\" => \"\"\n      .replaceAll(\n        /(removed \\d+ package), and audited \\d+ packages( in <variable>(?:s|ms|µs))\\n/g,\n        '$1$2\\n',\n      )\n      .replaceAll(/(up to date), audited \\d+ packages( in <variable>(?:s|ms|µs))\\n/g, '$1$2\\n')\n      .replaceAll(\n        /(added \\d+ packages?), and audited \\d+ packages( in <variable>(?:s|ms|µs))\\n/g,\n        '$1$2\\n',\n      )\n      .replaceAll(/\\nfound \\d+ vulnerabilities\\n/g, '')\n      // vite modules transformed count\n      .replaceAll(/✓ \\d+ modules? transformed/g, '✓ <variable> modules transformed')\n      // replace size for tsdown\n      .replaceAll(/ \\d+(\\.\\d+)? ([kKmMgG]?B)/g, ' <variable> $2')\n      // replace npm notice size:\n      // \"npm notice 5.6kB snap.txt\"\n      // \"npm notice 619B steps.json\"\n      .replaceAll(/ \\d+(\\.\\d+)?([kKmMgG]?B) /g, ' <variable>$2 ')\n      // '\"size\": 821' => '\"size\": <variable>'\n      // '\"unpackedSize\": 2720' => '\"unpackedSize\": <variable>'\n      .replaceAll(/\"(size|unpackedSize)\": \\d+/g, '\"$1\": <variable>')\n      // ignore npm registry domain\n      .replaceAll(/(https?:\\/\\/registry\\.)[^/\\s]+(\\/?)/g, '$1<domain>$2')\n      // ignore pnpm tarball download average speed warning log\n      .replaceAll(/ WARN  Tarball download average speed .+?\\n/g, '')\n      // ignore npm hash values\n      .replaceAll(/shasum: .+?\\n/g, 'shasum: <hash>\\n')\n      .replaceAll(/integrity: ([\\w-]+)-.+?\\n/g, 'integrity: $1-<hash>\\n')\n      .replaceAll(/\"shasum\": \".+?\"/g, '\"shasum\": \"<hash>\"')\n      .replaceAll(/\"integrity\": \"(\\w+)-.+?\"/g, '\"integrity\": \"$1-<hash>\"')\n      // replace homedir; e.g.: /Users/foo/Library/pnpm/global/5/node_modules/testnpm2 => <homedir>/Library/pnpm/global/5/node_modules/testnpm2\n      .replaceAll(homedir(), '<homedir>')\n      .replaceAll(/<homedir>\\/\\.vite-plus/g, '<vite-plus-home>')\n      // replace npm log file path with timestamp\n      // e.g.: <homedir>/.npm/_logs/<date>T07_38_18_387Z-debug-0.log => <homedir>/.npm/_logs/<timestamp>-debug.log\n      .replaceAll(\n        /(<homedir>\\/\\.npm\\/_logs\\/)<date>T\\d{2}_\\d{2}_\\d{2}_\\d+Z-debug-\\d+\\.log/g,\n        '$1<timestamp>-debug.log',\n      )\n      // remove the newline after \"Checking formatting...\"\n      .replaceAll(`Checking formatting...\\n`, 'Checking formatting...')\n      // remove warning <name>@<semver>: No license field\n      .replaceAll(/warning .+?: No license field\\n/g, '')\n      // remove \"npm warn exec The following package was not found and will be installed: cowsay@<semver>\"\n      .replaceAll(\n        /npm warn exec The following package was not found and will be installed: .+?\\n/g,\n        '',\n      )\n      // remove \"npm notice Access token expired or revoked...\"\n      .replaceAll(/npm notice Access token expired or revoked.+?\\n/g, '')\n      // remove mise reshimming messages (appears when global npm packages change)\n      .replaceAll(/Reshimming mise.+?\\n/g, '')\n      // remove plugin timings warnings (intermittent CI warnings)\n      // [PLUGIN_TIMINGS] Warning: Your build spent significant time in plugins. Here is a breakdown:\n      //   - externalize-deps (74%)\n      .replaceAll(/\\[PLUGIN_TIMINGS\\] Warning:.*?\\n(?:\\s+-\\s+.+?\\n)*/g, '')\n      // remove JS stack traces (lines starting with \"    at \")\n      .replaceAll(/\\n\\s+at .+/g, '')\n      // replace git stash hashes: \"git stash (abc1234)\" => \"git stash (<hash>)\"\n      .replaceAll(/git stash \\([0-9a-f]+\\)/g, 'git stash (<hash>)')\n      // normalize cat error spacing: Windows \"cat:file\" vs Unix \"cat: file\"\n      .replaceAll(/\\bcat:(\\S)/g, 'cat: $1')\n  );\n}\n\n// Exact matches for common environment variables\nconst DEFAULT_PASSTHROUGH_ENVS = [\n  // System and shell\n  'HOME',\n  'USER',\n  'TZ',\n  'LANG',\n  'SHELL',\n  'PWD',\n  'PATH',\n  // CI/CD environments\n  'CI',\n  // Node.js specific\n  'NODE_OPTIONS',\n  'COREPACK_HOME',\n  'NPM_CONFIG_STORE_DIR',\n  'PNPM_HOME',\n  // Library paths\n  'LD_LIBRARY_PATH',\n  'DYLD_FALLBACK_LIBRARY_PATH',\n  'LIBPATH',\n  // Terminal/display\n  'COLORTERM',\n  'TERM',\n  'TERM_PROGRAM',\n  'DISPLAY',\n  'FORCE_COLOR',\n  'NO_COLOR',\n  // Temporary directories\n  'TMP',\n  'TEMP',\n  // Vercel specific\n  'VERCEL',\n  'VERCEL_*',\n  'NEXT_*',\n  'USE_OUTPUT_FOR_EDGE_FUNCTIONS',\n  'NOW_BUILDER',\n  // Windows specific\n  'APPDATA',\n  'PROGRAMDATA',\n  'SYSTEMROOT',\n  'SYSTEMDRIVE',\n  'USERPROFILE',\n  'HOMEDRIVE',\n  'HOMEPATH',\n  'PATHEXT', // .EXE;.BAT;...\n  // IDE specific (exact matches)\n  'ELECTRON_RUN_AS_NODE',\n  'JB_INTERPRETER',\n  '_JETBRAINS_TEST_RUNNER_RUN_SCOPE_TYPE',\n  'JB_IDE_*',\n  // VSCode specific\n  'VSCODE_*',\n  // Docker specific\n  'DOCKER_*',\n  'BUILDKIT_*',\n  'COMPOSE_*',\n  // Token patterns\n  '*_TOKEN',\n  // oxc specific\n  'OXLINT_*',\n  // Rust specific\n  'RUST_*',\n  // Vite specific\n  'VITE_*',\n].map((env) => new Minimatch(env));\n\nexport function isPassThroughEnv(env: string) {\n  const upperEnv = env.toUpperCase();\n  return DEFAULT_PASSTHROUGH_ENVS.some((pattern) => pattern.match(upperEnv));\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - packages/*\n  - rolldown\n  - rolldown/packages/*\n  - vite\n  - vite/packages/*\ncatalog:\n  '@babel/core': ^7.24.7\n  '@babel/preset-env': ^7.24.7\n  '@babel/preset-typescript': ^7.24.7\n  '@clack/core': ^1.0.0\n  '@iconify/vue': ^5.0.0\n  '@napi-rs/cli': ^3.4.1\n  '@napi-rs/wasm-runtime': ^1.0.0\n  '@nkzw/safe-word-list': ^3.1.0\n  '@oxc-node/cli': ^0.0.35\n  '@oxc-node/core': ^0.0.35\n  '@oxc-project/runtime': =0.121.0\n  '@oxc-project/types': =0.122.0\n  '@pnpm/find-workspace-packages': ^6.0.9\n  '@rollup/plugin-commonjs': ^29.0.0\n  '@rollup/plugin-json': ^6.1.0\n  '@rollup/plugin-node-resolve': ^16.0.0\n  '@types/babel__core': 7.20.5\n  '@types/connect': ^3.4.38\n  '@types/cross-spawn': ^6.0.6\n  '@types/fs-extra': ^11.0.4\n  '@types/lodash-es': ^4.17.12\n  '@types/mocha': 10.0.10\n  '@types/node': 24.10.3\n  '@types/picomatch': ^4.0.0\n  '@types/react': ^19.1.8\n  '@types/react-dom': ^19.1.6\n  '@types/semver': ^7.7.1\n  '@types/serve-static': ^2.0.0\n  '@types/strip-comments': ^2.0.4\n  '@types/validate-npm-package-name': ^4.0.2\n  '@types/ws': ^8.18.0\n  '@typescript/native-preview': 7.0.0-dev.20260122.2\n  '@vitejs/plugin-react': ^5.1.2\n  '@vitest/browser': ^4.0.9\n  '@vitest/browser-playwright': ^4.0.9\n  '@voidzero-dev/vitepress-theme': 4.8.3\n  '@vueuse/core': ^14.0.0\n  '@yarnpkg/fslib': ^3.1.3\n  '@yarnpkg/shell': ^4.1.3\n  acorn: ^8.12.1\n  acorn-import-assertions: ^1.9.0\n  astring: ^1.9.0\n  bingo: ^0.9.2\n  buble: ^0.20.0\n  cac: ^7.0.0\n  chalk: ^5.3.0\n  change-case: ^5.4.4\n  connect: ^3.7.0\n  consola: ^3.4.2\n  cross-spawn: ^7.0.5\n  debug: ^4.4.3\n  dedent: ^1.5.3\n  detect-indent: ^7.0.2\n  detect-newline: ^4.0.1\n  diff: ^8.0.0\n  esbuild: ^0.27.0\n  estree-toolkit: ^1.7.8\n  execa: ^9.2.0\n  fast-glob: ^3.3.3\n  fixturify: ^3.0.0\n  follow-redirects: ^1.15.6\n  fs-extra: ^11.3.2\n  glob: ^13.0.0\n  husky: ^9.1.7\n  jsonc-parser: ^3.3.1\n  lint-staged: ^16.2.6\n  lodash-es: ^4.17.21\n  micromatch: ^4.0.9\n  minimatch: ^10.0.3\n  mocha: ^11.7.5\n  mri: ^1.2.0\n  next: ^15.4.3\n  oxc-minify: =0.121.0\n  oxc-parser: =0.121.0\n  oxc-transform: =0.121.0\n  oxfmt: =0.41.0\n  oxlint: =1.56.0\n  oxlint-tsgolint: =0.17.2\n  pathe: ^2.0.3\n  picocolors: ^1.1.1\n  picomatch: ^4.0.2\n  playwright: ^1.57.0\n  react: ^19.1.0\n  react-dom: ^19.1.0\n  remark-parse: ^11.0.0\n  remeda: ^2.10.0\n  rolldown-plugin-dts: ^0.22.0\n  rollup: ^4.18.0\n  semver: ^7.7.3\n  serve-static: ^2.0.0\n  signal-exit: 4.1.0\n  source-map: ^0.7.6\n  source-map-support: ^0.5.21\n  strip-comments: ^2.0.1\n  terser: ^5.44.1\n  tinybench: ^6.0.0\n  tinyexec: ^1.0.1\n  tsdown: ^0.21.4\n  tsx: ^4.20.6\n  typescript: ^5.9.3\n  unified: ^11.0.5\n  valibot: 1.3.1\n  validate-npm-package-name: ^7.0.2\n  vitepress: ^2.0.0-alpha.15\n  vitepress-plugin-graphviz: ^0.0.1\n  vitepress-plugin-group-icons: ^1.7.1\n  vitepress-plugin-llms: ^1.1.0\n  vitepress-plugin-og: ^0.0.5\n  vitest: ^4.0.15\n  vue: ^3.5.21\n  web-tree-sitter: ^0.26.0\n  ws: ^8.18.1\n  yaml: ^2.8.1\n  zod: ^4.3.5\n  zx: ^8.1.2\ncatalogMode: prefer\nignoreScripts: true\nminimumReleaseAge: 1440\nminimumReleaseAgeExclude:\n  - '@napi-rs/*'\n  - '@nkzw/*'\n  - '@oxc-minify/*'\n  - '@oxc-parser/*'\n  - '@oxc-project/*'\n  - '@oxc-resolver/*'\n  - '@oxc-transform/*'\n  - '@oxfmt/*'\n  - '@oxlint-tsgolint/*'\n  - '@oxlint/*'\n  - '@rolldown/*'\n  - '@tsdown/*'\n  - '@types/*'\n  - '@vitejs/*'\n  - '@vitest/*'\n  - '@voidzero-dev/*'\n  - oxc-minify\n  - oxc-parser\n  - oxc-transform\n  - oxfmt\n  - oxlint\n  - oxlint-tsgolint\n  - lightningcss\n  - lightningcss-*\n  - rolldown\n  - rolldown-plugin-dts\n  - tsdown\n  - unrun\n  - vite\n  - vitepress\n  - vitest\n  - vue-virtual-scroller\noverrides:\n  '@rolldown/pluginutils': workspace:@rolldown/pluginutils@*\n  rolldown: workspace:rolldown@*\n  vite: workspace:@voidzero-dev/vite-plus-core@*\n  vitest: workspace:@voidzero-dev/vite-plus-test@*\n  vitest-dev: npm:vitest@^4.1.1\npackageExtensions:\n  sass-embedded:\n    peerDependencies:\n      source-map-js: '*'\n    peerDependenciesMeta:\n      source-map-js:\n        optional: true\n  '@rollup/plugin-dynamic-import-vars':\n    dependencies:\n      '@types/estree': ^1.0.0\npatchedDependencies:\n  sirv@3.0.2: vite/patches/sirv@3.0.2.patch\n  chokidar@3.6.0: vite/patches/chokidar@3.6.0.patch\n  dotenv-expand@12.0.3: vite/patches/dotenv-expand@12.0.3.patch\npeerDependencyRules:\n  allowAny:\n    - vite\n    - vitest\n    - rolldown\n  allowedVersions:\n    oxc-minify: '*'\n    oxlint-tsgolint: '*'\n    rolldown: '*'\n    vite: '*'\n"
  },
  {
    "path": "rfcs/add-remove-package-commands.md",
    "content": "# RFC: Vite+ Add and Remove Package Commands\n\n## Summary\n\nAdd `vp add` and `vp remove` commands that automatically adapt to the detected package manager (pnpm/yarn/npm) for adding and removing packages, with support for multiple packages, common flags, and workspace-aware operations based on pnpm's API design.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands:\n\n```bash\npnpm add react\nyarn add react\nnpm install react\n```\n\nThis creates friction in monorepo workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify workflows**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm add -D typescript  # pnpm project\nyarn add --dev typescript  # yarn project\nnpm install --save-dev typescript  # npm project\n\n# Different remove commands\npnpm remove lodash\nyarn remove lodash\nnpm uninstall lodash\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp add typescript -D\nvp remove lodash\n\n# Multiple packages\nvp add react react-dom\nvp remove axios lodash\n\n# Workspace operations\nvp add react --filter app\nvp add @myorg/utils --workspace --filter app\nvp add lodash -w  # Add to workspace root\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n#### Add Command\n\n```bash\nvp add <PACKAGES>... [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Add production dependency\nvp add react react-dom\n\n# Add dev dependency\nvp add -D typescript @types/react\n\n# Add with exact version\nvp add react -E\n\n# Add peer dependency\nvp add --save-peer react\n\n# Add optional dependency\nvp add -O sharp\n\n# Workspace operations\nvp add react --filter app              # Add to specific package\nvp add @myorg/utils --workspace --filter app  # Add workspace dependency\nvp add lodash -w                       # Add to workspace root\nvp add react --filter \"app*\"           # Add to multiple packages (pattern)\nvp add utils --filter \"!@myorg/core\"   # Exclude packages\n```\n\n##### `vp install` Command with `PACKAGES` arguments\n\nTo accommodate the user habits and experience of `npm install <PACKAGES>…`, `vp install <PACKAGES>...` will be specially treated as an alias for the add command.\n\nThe following commands will be automatically converted to the add command for processing:\n\n```bash\nvp install <PACKAGES>... [OPTIONS]\n\n-> vp add <PACKAGES>... [OPTIONS]\n```\n\n##### Install global packages with npm cli only\n\nFor global packages, we will use npm cli only.\n\n> Because yarn do not support global packages install on [version>=2.x](https://yarnpkg.com/migration/guide#use-yarn-dlx-instead-of-yarn-global), and pnpm global install has some bugs like `wrong bin file` issues.\n\n```bash\nvp install -g <PACKAGES>...\nvp add -g <PACKAGES>...\n\n-> npm install -g <PACKAGES>...\n```\n\n#### Remove Command\n\n```bash\nvp remove <PACKAGES>... [OPTIONS]\nvp rm <PACKAGES>... [OPTIONS]        # Alias\n```\n\n**Examples:**\n\n```bash\n# Remove packages\nvp remove lodash axios\n\n# Remove dev dependency\nvp rm typescript\n\n# Alias support\nvp rm old-package\n\n# Workspace operations\nvp remove lodash --filter app          # Remove from specific package\nvp rm utils --filter \"app*\"            # Remove from multiple packages\nvp remove -g typescript                # Remove global package\n```\n\n### Command Mapping\n\n#### Add Command Mapping\n\n- https://pnpm.io/cli/add#options\n- https://yarnpkg.com/cli/add#options\n- https://docs.npmjs.com/cli/v11/commands/npm-install#description\n\n| Vite+ Flag                           | pnpm                     | yarn                                            | npm                             | Description                                             |\n| ------------------------------------ | ------------------------ | ----------------------------------------------- | ------------------------------- | ------------------------------------------------------- |\n| `<packages>`                         | `add <packages>`         | `add <packages>`                                | `install <packages>`            | Add packages                                            |\n| `--filter <pattern>`                 | `--filter <pattern> add` | `workspaces foreach -A --include <pattern> add` | `install --workspace <pattern>` | Target specific workspace package(s)                    |\n| `-w, --workspace-root`               | `-w`                     | `-W` for v1, v2+ N/A                            | `--include-workspace-root`      | Add to workspace root (ignore-workspace-root-check)     |\n| `--workspace`                        | `--workspace`            | N/A                                             | N/A                             | Only add if package exists in workspace (pnpm-specific) |\n| `-P, --save-prod`                    | `--save-prod` / `-P`     | N/A                                             | `--save-prod` / `-P`            | Save to `dependencies`. The default behavior            |\n| `-D, --save-dev`                     | `-D`                     | `--dev` / `-D`                                  | `--save-dev` / `-D`             | Save to `devDependencies`                               |\n| `--save-peer`                        | `--save-peer`            | `--peer` / `-P`                                 | `--save-peer`                   | Save to `peerDependencies` and `devDependencies`        |\n| `-O, --save-optional`                | `-O`                     | `--optional` / `-O`                             | `--save-optional` / `-O`        | Save to `optionalDependencies`                          |\n| `-E, --save-exact`                   | `-E`                     | `--exact` / `-E`                                | `--save-exact` / `-E`           | Save exact version                                      |\n| `-g, --global`                       | `-g`                     | `global add`                                    | `--global` / `-g`               | Install globally                                        |\n| `--save-catalog`                     | pnpm@10+ only            | N/A                                             | N/A                             | Save the new dependency to the default catalog          |\n| `--save-catalog-name <catalog_name>` | pnpm@10+ only            | N/A                                             | N/A                             | Save the new dependency to the specified catalog        |\n| `--allow-build <names>`              | pnpm@10+ only            | N/A                                             | N/A                             | A list of package names allowed to run postinstall      |\n\n**Note**: For pnpm, `--filter` must come before the command (e.g., `pnpm --filter app add react`). For yarn/npm, it's integrated into the command structure.\n\n#### Remove Command Mapping\n\n- https://pnpm.io/cli/remove#options\n- https://yarnpkg.com/cli/remove#options\n- https://docs.npmjs.com/cli/v11/commands/npm-uninstall#description\n\n| Vite+ Flag             | pnpm                        | yarn                                               | npm                               | Description                                    |\n| ---------------------- | --------------------------- | -------------------------------------------------- | --------------------------------- | ---------------------------------------------- |\n| `<packages>`           | `remove <packages>`         | `remove <packages>`                                | `uninstall <packages>`            | Remove packages                                |\n| `-D, --save-dev`       | `-D`                        | N/A                                                | `--save-dev` / `-D`               | Only remove from `devDependencies`             |\n| `-O, --save-optional`  | `-O`                        | N/A                                                | `--save-optional` / `-O`          | Only remove from `optionalDependencies`        |\n| `-P, --save-prod`      | `-P`                        | N/A                                                | `--save-prod` / `-P`              | Only remove from `dependencies`                |\n| `--filter <pattern>`   | `--filter <pattern> remove` | `workspaces foreach -A --include <pattern> remove` | `uninstall --workspace <pattern>` | Target specific workspace package(s)           |\n| `-w, --workspace-root` | `-w`                        | N/A                                                | `--include-workspace-root`        | Remove from workspace root                     |\n| `-r, --recursive`      | `-r, --recursive`           | `-A, --all`                                        | `--workspaces`                    | Remove recursively from all workspace packages |\n| `-g, --global`         | `-g`                        | N/A                                                | `--global` / `-g`                 | Remove global packages                         |\n\n**Note**: Similar to add, `--filter` must precede the command for pnpm.\n\n**Aliases:**\n\n- `vp rm` = `vp remove`\n- `vp un` = `vp remove`\n- `vp uninstall` = `vp remove`\n\n#### Workspace Filter Patterns\n\nBased on pnpm's filter syntax:\n\n| Pattern      | Description              | Example                                    |\n| ------------ | ------------------------ | ------------------------------------------ |\n| `<pkg-name>` | Exact package name       | `--filter app`                             |\n| `<pattern>*` | Wildcard match           | `--filter \"app*\"` matches app, app-web     |\n| `@<scope>/*` | Scope match              | `--filter \"@myorg/*\"`                      |\n| `!<pattern>` | Exclude pattern          | `--filter \"!test*\"` excludes test packages |\n| `<pkg>...`   | Package and dependencies | `--filter \"app...\"`                        |\n| `...<pkg>`   | Package and dependents   | `--filter \"...utils\"`                      |\n\n**Multiple Filters**:\n\n```bash\nvp add react --filter app --filter web  # Add to both app and web\nvp add react --filter \"app*\" --filter \"!app-test\"  # Add to app* except app-test\n```\n\n#### Pass-Through Arguments\n\nAdditional parameters not covered by Vite+ can all be handled through pass-through arguments.\n\nAll arguments after `--` will be passed through to the package manager.\n\n```bash\nvp add react --allow-build=react,napi -- --use-stderr\n\n-> pnpm add --allow-build=react,napi --use-stderr react\n-> yarn add --use-stderr react\n-> npm install --use-stderr react\n```\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command variants:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Add packages to dependencies\n    #[command(disable_help_flag = true)]\n    Add {\n        /// Packages to add\n        packages: Vec<String>,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Vec<String>,\n\n        /// Add to workspace root (ignore-workspace-root-check)\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Only add if package exists in workspace\n        #[arg(long)]\n        workspace: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Remove packages from dependencies\n    #[command(disable_help_flag = true, alias = \"rm\", alias = \"un\", alias = \"uninstall\")]\n    Remove {\n        /// Packages to remove\n        packages: Vec<String>,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Vec<String>,\n\n        /// Remove from workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/package_manager.rs`\n\nAdd methods to translate commands:\n\n```rust\nimpl PackageManager {\n    /// Resolve add command for the package manager\n    pub fn resolve_add_command(&self) -> &'static str {\n        match self.client {\n            PackageManagerType::Pnpm => \"add\",\n            PackageManagerType::Yarn => \"add\",\n            PackageManagerType::Npm => \"install\",\n        }\n    }\n\n    /// Resolve remove command for the package manager\n    pub fn resolve_remove_command(&self) -> &'static str {\n        match self.client {\n            PackageManagerType::Pnpm => \"remove\",\n            PackageManagerType::Yarn => \"remove\",\n            PackageManagerType::Npm => \"uninstall\",\n        }\n    }\n\n    /// Build command arguments with workspace support\n    pub fn build_add_args(\n        &self,\n        packages: &[String],\n        filters: &[String],\n        workspace_root: bool,\n        workspace_only: bool,\n        extra_args: &[String],\n    ) -> Vec<String> {\n        let mut args = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                // pnpm: --filter must come before command\n                for filter in filters {\n                    args.push(\"--filter\".to_string());\n                    args.push(filter.clone());\n                }\n                args.push(\"add\".to_string());\n                args.extend_from_slice(packages);\n                if workspace_root {\n                    args.push(\"-w\".to_string());\n                }\n                if workspace_only {\n                    args.push(\"--workspace\".to_string());\n                }\n                args.extend_from_slice(extra_args);\n            }\n            PackageManagerType::Yarn => {\n                // yarn: workspace <pkg> add\n                if !filters.is_empty() {\n                    // yarn workspace <name> add\n                    for filter in filters {\n                        args.push(\"workspace\".to_string());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"add\".to_string());\n                args.extend_from_slice(packages);\n                if workspace_root {\n                    args.push(\"-W\".to_string());\n                }\n                args.extend_from_slice(extra_args);\n            }\n            PackageManagerType::Npm => {\n                // npm: --workspace must come before install\n                if !filters.is_empty() {\n                    for filter in filters {\n                        args.push(\"--workspace\".to_string());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"install\".to_string());\n                args.extend_from_slice(packages);\n                if workspace_root {\n                    args.push(\"-w\".to_string());\n                }\n                args.extend_from_slice(extra_args);\n            }\n        }\n\n        args\n    }\n\n    /// Build remove command arguments with workspace support\n    pub fn build_remove_args(\n        &self,\n        packages: &[String],\n        filters: &[String],\n        workspace_root: bool,\n        extra_args: &[String],\n    ) -> Vec<String> {\n        let mut args = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                for filter in filters {\n                    args.push(\"--filter\".to_string());\n                    args.push(filter.clone());\n                }\n                args.push(\"remove\".to_string());\n                args.extend_from_slice(packages);\n                if workspace_root {\n                    args.push(\"-w\".to_string());\n                }\n                args.extend_from_slice(extra_args);\n            }\n            PackageManagerType::Yarn => {\n                if !filters.is_empty() {\n                    for filter in filters {\n                        args.push(\"workspace\".to_string());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"remove\".to_string());\n                args.extend_from_slice(packages);\n                args.extend_from_slice(extra_args);\n            }\n            PackageManagerType::Npm => {\n                if !filters.is_empty() {\n                    for filter in filters {\n                        args.push(\"--workspace\".to_string());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"uninstall\".to_string());\n                args.extend_from_slice(packages);\n                args.extend_from_slice(extra_args);\n            }\n        }\n\n        args\n    }\n}\n```\n\n#### 3. Add Command Implementation\n\n**File**: `crates/vite_task/src/add.rs` (new file)\n\n```rust\npub struct AddCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl AddCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        packages: Vec<String>,\n        filters: Vec<String>,\n        workspace_root: bool,\n        workspace_only: bool,\n        extra_args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        let resolve_command = package_manager.resolve_command();\n\n        // Build command with workspace support\n        let full_args = package_manager.build_add_args(\n            &packages,\n            &filters,\n            workspace_root,\n            workspace_only,\n            &extra_args,\n        );\n\n        let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result(\n            &workspace,\n            \"add\",\n            full_args.iter().map(String::as_str),\n            ResolveCommandResult {\n                bin_path: resolve_command.bin_path,\n                envs: resolve_command.envs,\n            },\n            false,\n        )?;\n\n        let mut task_graph: StableGraph<ResolvedTask, ()> = Default::default();\n        task_graph.add_node(resolved_task);\n        let summary = ExecutionPlan::plan(task_graph, false)?.execute(&workspace).await?;\n        workspace.unload().await?;\n\n        Ok(summary)\n    }\n}\n```\n\n#### 4. Remove Command Implementation\n\n**File**: `crates/vite_task/src/remove.rs` (new file)\n\n```rust\npub struct RemoveCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl RemoveCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        packages: Vec<String>,\n        filters: Vec<String>,\n        workspace_root: bool,\n        extra_args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        let resolve_command = package_manager.resolve_command();\n\n        // Build command with workspace support\n        let full_args = package_manager.build_remove_args(\n            &packages,\n            &filters,\n            workspace_root,\n            &extra_args,\n        );\n\n        let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result(\n            &workspace,\n            \"remove\",\n            full_args.iter().map(String::as_str),\n            ResolveCommandResult {\n                bin_path: resolve_command.bin_path,\n                envs: resolve_command.envs,\n            },\n            false,\n        )?;\n\n        let mut task_graph: StableGraph<ResolvedTask, ()> = Default::default();\n        task_graph.add_node(resolved_task);\n        let summary = ExecutionPlan::plan(task_graph, false)?.execute(&workspace).await?;\n        workspace.unload().await?;\n\n        Ok(summary)\n    }\n}\n```\n\n### Special Handling\n\n#### 1. Global Packages\n\nYarn requires different command structure for global operations:\n\n```rust\n// pnpm/npm: <bin> add -g <package>\n// yarn: <bin> global add <package>\n\nfn handle_global_flag(args: &[String], pm_type: PackageManagerType) -> (Vec<String>, bool) {\n    let has_global = args.contains(&\"-g\".to_string()) || args.contains(&\"--global\".to_string());\n    let filtered_args: Vec<String> = args.iter()\n        .filter(|a| *a != \"-g\" && *a != \"--global\")\n        .cloned()\n        .collect();\n\n    (filtered_args, has_global)\n}\n```\n\n#### 2. Workspace Filters\n\npnpm uses `--filter` before command, yarn/npm use different approaches:\n\n```rust\nfn build_workspace_command(\n    pm_type: PackageManagerType,\n    filters: &[String],\n    operation: &str,\n    packages: &[String],\n) -> Vec<String> {\n    match pm_type {\n        PackageManagerType::Pnpm => {\n            // pnpm --filter <pkg> add <deps>\n            let mut args = Vec::new();\n            for filter in filters {\n                args.push(\"--filter\".to_string());\n                args.push(filter.clone());\n            }\n            args.push(operation.to_string());\n            args.extend_from_slice(packages);\n            args\n        }\n        PackageManagerType::Yarn => {\n            // yarn workspace <pkg> add <deps>\n            let mut args = Vec::new();\n            if let Some(filter) = filters.first() {\n                args.push(\"workspace\".to_string());\n                args.push(filter.clone());\n            }\n            args.push(operation.to_string());\n            args.extend_from_slice(packages);\n            args\n        }\n        PackageManagerType::Npm => {\n            // npm install <deps> --workspace <pkg>\n            let mut args = vec![operation.to_string()];\n            args.extend_from_slice(packages);\n            for filter in filters {\n                args.push(\"--workspace\".to_string());\n                args.push(filter.clone());\n            }\n            args\n        }\n    }\n}\n```\n\n#### 3. Workspace Dependencies\n\nWhen adding workspace dependencies with `--workspace` flag:\n\n```bash\n# pnpm: Adds with workspace: protocol\nvp add @myorg/utils --workspace --filter app\n# → pnpm --filter app add @myorg/utils --workspace\n# → Adds: \"@myorg/utils\": \"workspace:*\"\n\n# Without --workspace: Tries to install from registry\nvp add @myorg/utils --filter app\n# → pnpm --filter app add @myorg/utils\n# → Tries npm registry (may fail if not published)\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache add/remove operations.\n\n**Rationale**:\n\n- These commands modify package.json and lockfiles\n- Side effects make caching inappropriate\n- Each execution should run fresh\n- Similar to how `vp install` works\n\n**Implementation**: Set `cacheable: false` or skip cache entirely.\n\n### 2. Pass-Through Arguments\n\n**Decision**: Pass all arguments after packages directly to package manager.\n\n**Rationale**:\n\n- Package managers have many flags (40+ for npm)\n- Maintaining complete flag mapping is error-prone\n- Pass-through allows accessing all features\n- Only translate critical command name differences\n\n**Example**:\n\n```bash\nvp add react --save-exact\n# → pnpm add react --save-exact\n# → yarn add react --save-exact\n# → npm install react --save-exact\n```\n\n### 3. Common Flags Only\n\n**Decision**: Only explicitly support most common flags with automatic translation.\n\n**Common Flags**:\n\n- `-D, --save-dev` - universally supported\n- `-g, --global` - needs special handling for yarn\n- `-E, --save-exact` - universally supported\n- `-P, --save-peer` - universally supported\n- `-O, --save-optional` - universally supported\n\n**Advanced Flags**: Pass through as-is\n\n### 4. Command Aliases\n\n**Decision**: Support multiple aliases for remove command.\n\n**Aliases**:\n\n- `vp remove` (primary)\n- `vp rm` (short)\n- `vp un` (short, matches pnpm)\n- `vp uninstall` (explicit, matches npm)\n\n**Rationale**: Matches user expectations from other tools.\n\n### 5. Multiple Package Support\n\n**Decision**: Allow specifying multiple packages in single command.\n\n**Example**:\n\n```bash\nvp add react react-dom @types/react -D\nvp remove lodash axios underscore\n```\n\n**Implementation**: Packages are positional arguments before flags.\n\n## Error Handling\n\n### No Packages Specified\n\n```bash\n$ vp add\nError: No packages specified\nUsage: vp add <PACKAGES>... [OPTIONS]\n```\n\n### Package Manager Not Detected\n\n```bash\n$ vp add react\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### Invalid Package Names\n\nLet the underlying package manager handle validation and provide clear errors.\n\n## User Experience\n\n### Success Output\n\n```bash\n$ vp add react react-dom\nDetected package manager: pnpm@10.15.0\nRunning: pnpm add react react-dom\n\n WARN  deprecated inflight@1.0.6: ...\n\nPackages: +2\n++\nProgress: resolved 150, reused 140, downloaded 10, added 2, done\n\ndependencies:\n+ react 18.3.1\n+ react-dom 18.3.1\n\nDone in 2.3s\n```\n\n### Error Output\n\n```bash\n$ vp add invalid-package-that-does-not-exist\nDetected package manager: pnpm@10.15.0\nRunning: pnpm add invalid-package-that-does-not-exist\n\n ERR_PNPM_FETCH_404  GET https://registry.npmjs.org/invalid-package-that-does-not-exist: Not Found - 404\n\nThis error happened while installing the dependencies of undefined@undefined\n\nError: Command failed with exit code 1\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Flag Translation Layer\n\nTranslate all flags to package manager-specific equivalents:\n\n```bash\nvp add react --dev\n# → pnpm add react -D\n# → yarn add react --dev\n# → npm install react --save-dev\n```\n\n**Rejected because**:\n\n- Maintenance burden (40+ npm flags)\n- Package managers evolve with new flags\n- Pass-through is simpler and more flexible\n- Users can use native flags directly\n\n### Alternative 2: Separate Commands per Package Manager\n\n```bash\nvp pnpm:add react\nvp yarn:add react\nvp npm:install react\n```\n\n**Rejected because**:\n\n- Defeats purpose of unified interface\n- More verbose\n- Doesn't leverage auto-detection\n\n### Alternative 3: Interactive Mode\n\nPrompt for packages and options interactively:\n\n```bash\n$ vp add\n? Which packages to add? react\n? Add as dev dependency? Yes\n```\n\n**Rejected for initial version**:\n\n- Slower for experienced users\n- Not scriptable\n- Can be added later as optional mode\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Add` and `Remove` command variants to `Commands` enum\n2. Create `add.rs` and `remove.rs` modules\n3. Implement package manager command resolution\n4. Add basic error handling\n\n### Phase 2: Special Cases\n\n1. Handle yarn global commands differently\n2. Validate package names (optional)\n3. Support workspace-specific operations\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Integration tests with mock package managers\n3. Manual testing with real package managers\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document flag compatibility matrix\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x [WIP]\n- pnpm@10.x\n- yarn@1.x [WIP]\n- yarn@4.x\n- npm@10.x\n- npm@11.x [WIP]\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_add_command_resolution() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    assert_eq!(pm.resolve_add_command(), \"add\");\n\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    assert_eq!(pm.resolve_add_command(), \"install\");\n}\n\n#[test]\nfn test_remove_command_resolution() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    assert_eq!(pm.resolve_remove_command(), \"remove\");\n\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    assert_eq!(pm.resolve_remove_command(), \"uninstall\");\n}\n\n#[test]\nfn test_build_add_args_pnpm() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.build_add_args(\n        &[\"react\".to_string()],\n        &[\"app\".to_string()],\n        false,\n        false,\n        &[],\n    );\n    assert_eq!(args, vec![\"--filter\", \"app\", \"add\", \"react\"]);\n}\n\n#[test]\nfn test_build_add_args_with_workspace_root() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.build_add_args(\n        &[\"typescript\".to_string()],\n        &[],\n        true,  // workspace_root\n        false,\n        &[\"-D\".to_string()],\n    );\n    assert_eq!(args, vec![\"add\", \"typescript\", \"-w\", \"-D\"]);\n}\n\n#[test]\nfn test_build_add_args_yarn_workspace() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let args = pm.build_add_args(\n        &[\"react\".to_string()],\n        &[\"app\".to_string()],\n        false,\n        false,\n        &[],\n    );\n    assert_eq!(args, vec![\"workspace\", \"app\", \"add\", \"react\"]);\n}\n\n#[test]\nfn test_build_remove_args_with_filter() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.build_remove_args(\n        &[\"lodash\".to_string()],\n        &[\"utils\".to_string()],\n        false,\n        &[],\n    );\n    assert_eq!(args, vec![\"--filter\", \"utils\", \"remove\", \"lodash\"]);\n}\n```\n\n### Integration Tests\n\nCreate fixtures for testing with each package manager:\n\n```\nfixtures/add-remove-test/\n  pnpm-workspace.yaml\n  package.json\n  packages/\n    app/\n      package.json\n    utils/\n      package.json\n  test-steps.json\n```\n\nTest cases:\n\n1. Add single package\n2. Add multiple packages\n3. Add with -D flag\n4. Add with --filter to specific package\n5. Add with --filter wildcard pattern\n6. Add to workspace root with -w\n7. Add workspace dependency with --workspace\n8. Remove single package\n9. Remove multiple packages\n10. Remove with --filter\n11. Error handling for invalid packages\n12. Error handling for incompatible filters on yarn/npm\n\n## CLI Help Output\n\n### Add Command\n\n```bash\n$ vp add --help\nAdd packages to dependencies\n\nUsage: vp add <PACKAGES>... [OPTIONS]\n\nArguments:\n  <PACKAGES>...  Packages to add\n\nOptions:\n  --filter <PATTERN>   Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root Add to workspace root (ignore-workspace-root-check)\n  --workspace          Only add if package exists in workspace\n  -D, --save-dev       Add as dev dependency\n  -P, --save-peer      Add as peer dependency\n  -O, --save-optional  Add as optional dependency\n  -E, --save-exact     Save exact version\n  -g, --global         Install globally\n  -h, --help           Print help\n\nFilter Patterns:\n  <name>           Exact package name match\n  <pattern>*       Wildcard match (pnpm only)\n  @<scope>/*       Scope match (pnpm only)\n  !<pattern>       Exclude pattern (pnpm only)\n  <pkg>...         Package and dependencies (pnpm only)\n  ...<pkg>         Package and dependents (pnpm only)\n\nExamples:\n  vp add react react-dom\n  vp add -D typescript @types/react\n  vp add react --filter app\n  vp add react --filter \"app*\" --filter \"!app-test\"\n  vp add @myorg/utils --workspace --filter web\n  vp add lodash -w\n```\n\n### Remove Command\n\n```bash\n$ vp remove --help\nRemove packages from dependencies\n\nUsage: vp remove <PACKAGES>... [OPTIONS]\n\nAliases: rm, un, uninstall\n\nArguments:\n  <PACKAGES>...  Packages to remove\n\nOptions:\n  --filter <PATTERN>   Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root Remove from workspace root\n  -g, --global         Remove global packages\n  -h, --help           Print help\n\nFilter Patterns:\n  <name>           Exact package name match\n  <pattern>*       Wildcard match (pnpm only)\n  @<scope>/*       Scope match (pnpm only)\n  !<pattern>       Exclude pattern (pnpm only)\n\nExamples:\n  vp remove lodash\n  vp remove axios underscore lodash\n  vp rm lodash --filter app\n  vp remove utils --filter \"app*\"\n  vp rm old-package\n```\n\n## Performance Considerations\n\n1. **No Caching**: Operations run directly without cache overhead\n2. **Single Execution**: Unlike task runner, these are one-off operations\n3. **Pass-Through**: Minimal processing, just command translation\n4. **Auto-Detection**: Reuses existing package manager detection (already cached)\n\n## Security Considerations\n\n1. **Package Name Validation**: Let package manager handle validation\n2. **Lockfile Integrity**: Package manager ensures integrity\n3. **No Code Execution**: Just passes through to trusted package manager\n4. **Audit Flags**: Users can add `--audit` via pass-through\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New commands are additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Migration Path\n\n### Adoption\n\nUsers can start using immediately:\n\n```bash\n# Old way\npnpm add react\n\n# New way (works with any package manager)\nvp add react\n```\n\n### Discoverability\n\nAdd to:\n\n- CLI help output\n- Documentation\n- VSCode extension suggestions\n- Shell completions\n\n## Documentation Requirements\n\n### User Guide\n\nAdd to CLI documentation:\n\n````markdown\n### Adding Packages\n\n```bash\nvp add <packages>... [OPTIONS]\n```\n````\n\nAutomatically uses the detected package manager (pnpm/yarn/npm).\n\n**Basic Examples:**\n\n- `vp add react` - Add production dependency\n- `vp add -D typescript` - Add dev dependency\n- `vp add react react-dom` - Add multiple packages\n\n**Workspace Examples:**\n\n- `vp add react --filter app` - Add to specific package\n- `vp add react --filter \"app*\"` - Add to multiple packages (pnpm)\n- `vp add @myorg/utils --workspace --filter web` - Add workspace dependency\n- `vp add lodash -w` - Add to workspace root\n\n**Common Options:**\n\n- `--filter <pattern>` - Target specific workspace package(s)\n- `-w, --workspace-root` - Add to workspace root\n- `--workspace` - Add workspace dependency (pnpm)\n- `-D, --save-dev` - Add as dev dependency\n- `-E, --save-exact` - Save exact version\n- `-P, --save-peer` - Add as peer dependency\n- `-O, --save-optional` - Add as optional dependency\n- `-g, --global` - Install globally\n\n### Removing Packages\n\n```bash\nvp remove <packages>... [OPTIONS]\nvp rm <packages>... [OPTIONS]\n```\n\nAliases: `rm`, `un`, `uninstall`\n\n**Basic Examples:**\n\n- `vp remove lodash` - Remove package\n- `vp rm axios underscore` - Remove multiple packages\n\n**Workspace Examples:**\n\n- `vp remove lodash --filter app` - Remove from specific package\n- `vp rm utils --filter \"app*\"` - Remove from multiple packages (pnpm)\n- `vp remove -g typescript` - Remove global package\n\n**Options:**\n\n- `--filter <pattern>` - Target specific workspace package(s)\n- `-w, --workspace-root` - Remove from workspace root\n- `-g, --global` - Remove global packages\n\n````\n### Package Manager Compatibility\n\nDocument flag support matrix:\n\n| Flag | pnpm | yarn | npm |\n|------|------|------|-----|\n| `-D` | ✅ | ✅ | ✅ |\n| `-E` | ✅ | ✅ | ✅ |\n| `-P` | ✅ | ✅ | ✅ |\n| `-O` | ✅ | ✅ | ✅ |\n| `-g` | ✅ | ⚠️ (use global) | ✅ |\n\n## Workspace Operations Deep Dive\n\n### Filter Patterns (pnpm-inspired)\n\nFollowing pnpm's filter API:\n\n**Exact Match:**\n```bash\nvp add react --filter app\n# → pnpm --filter app add react\n````\n\n**Wildcard Patterns:**\n\n```bash\nvp add react --filter \"app*\"\n# → pnpm --filter \"app*\" add react\n# Matches: app, app-web, app-mobile\n```\n\n**Scope Patterns:**\n\n```bash\nvp add lodash --filter \"@myorg/*\"\n# → pnpm --filter \"@myorg/*\" add lodash\n# Matches all packages in @myorg scope\n```\n\n**Exclusion Patterns:**\n\n```bash\nvp add react --filter \"!test*\"\n# → pnpm --filter \"!test*\" add react\n# Adds to all packages EXCEPT those starting with test\n```\n\n**Multiple Filters:**\n\n```bash\nvp add react --filter app --filter web\n# → pnpm --filter app --filter web add react\n# Adds to both app AND web packages\n```\n\n**Dependency Selectors:**\n\n```bash\n# Add to package and all its dependencies\nvp add lodash --filter \"app...\"\n# → pnpm --filter \"app...\" add lodash\n\n# Add to package and all its dependents\nvp add utils --filter \"...core\"\n# → pnpm --filter \"...core\" add utils\n```\n\n### Workspace Root Operations\n\nAdd dependencies to workspace root (requires special flag):\n\n```bash\nvp add -D typescript -w\n# → pnpm add -D typescript -w  (pnpm)\n# → yarn add -D typescript -W  (yarn)\n# → npm install -D typescript -w  (npm)\n```\n\n**Why needed**: By default, package managers prevent adding to workspace root to encourage proper package structure.\n\n### Workspace Protocol\n\nFor internal monorepo dependencies:\n\n```bash\n# Add workspace dependency with workspace: protocol\nvp add @myorg/utils --workspace --filter app\n# → pnpm --filter app add @myorg/utils --workspace\n# → Adds: \"@myorg/utils\": \"workspace:*\"\n\n# Specify version\nvp add \"@myorg/utils@workspace:^\" --filter app\n# → Adds: \"@myorg/utils\": \"workspace:^\"\n```\n\n### Package Manager Compatibility\n\n| Feature                    | pnpm               | yarn                  | npm                     | Notes                    |\n| -------------------------- | ------------------ | --------------------- | ----------------------- | ------------------------ |\n| `--filter <pattern>`       | ✅ Native          | ⚠️ `workspace <name>` | ⚠️ `--workspace <name>` | Syntax differs           |\n| Multiple filters           | ✅ Repeatable flag | ❌ Single only        | ⚠️ Limited              | pnpm most flexible       |\n| Wildcard patterns          | ✅ Full support    | ⚠️ Limited            | ❌ No wildcards         | pnpm best                |\n| Exclusion `!`              | ✅ Supported       | ❌ Not supported      | ❌ Not supported        | pnpm only                |\n| Dependency selectors `...` | ✅ Supported       | ❌ Not supported      | ❌ Not supported        | pnpm only                |\n| `-w` (root)                | ✅ `-w`            | ✅ `-W`               | ✅ `-w`                 | Slightly different flags |\n| `--workspace` protocol     | ✅ Supported       | ❌ Manual             | ❌ Manual               | pnpm feature             |\n\n**Graceful Degradation**:\n\n- Advanced pnpm features (wildcard, exclusion, selectors) will error on yarn/npm with helpful message\n- Basic `--filter <exact-name>` works across all package managers\n\n## Future Enhancements\n\n### 1. Enhanced Filter Support for yarn/npm\n\nImplement wildcard translation for yarn/npm:\n\n```bash\nvp add react --filter \"app*\"\n# → For yarn: Run `yarn workspace app add react` for each matching package\n# → For npm: Run `npm install react --workspace app` for each matching package\n```\n\n### 2. Interactive Mode\n\n> Referer to ni's interactive mode https://github.com/antfu-collective/ni\n\n```bash\n$ vp add --interactive\n? Select for package > tsdown\n❯   tsdown                         v0.15.7 - git+https://github.com/rolldown/tsdown.git\n    tsdown-config-silverwind       v1.4.0 - git+https://github.com/silverwind/tsdown-config-silverwind.git\n    @storm-software/tsdown         v0.45.0 - git+https://github.com/storm-software/storm-ops.git\n    create-tsdown                  v0.15.7 - git+https://github.com/rolldown/tsdown.git\n    shadcn-auv                     v0.0.1 - git+https://github.com/ohojs/shadcn-auv.git\n    ts-build-wizard                v1.0.3 - git+https://github.com/Alireza-Tabatabaeian/react-app-registry.git\n    vite-plugin-shadcn-registry    v0.0.6 - git+https://github.com/myshkouski/vite-plugin-shadcn-registry.git\n    @qds.dev/tools                 v0.3.3 - https://www.npmjs.com/package/@qds.dev/tools\n    feishu-bot-notify              v0.1.3 - git+https://github.com/duowb/feishu-bot-notify.git\n    @memo28.pro/bundler            v0.0.2 - https://www.npmjs.com/package/@memo28.pro/bundler\n    tsdown-jsr-exports-lint        v0.1.4 - git+https://github.com/kazupon/tsdown-jsr-exports-lint.git\n    @miloas/tsdown                 v0.13.0 - git+https://github.com/rolldown/tsdown.git\n    @socket-synced-state/server    v0.0.9 - https://www.npmjs.com/package/@socket-synced-state/server\n    @gamedev-sensei/tsdown-config  v2.0.1 - git+ssh://git@github.com/gamedev-sensei/package-extras.git\n  ↓ 0xpresc-test                   v0.1.0 - https://www.npmjs.com/package/0xpresc-test\n\n? install tsdown as › - Use arrow-keys. Return to submit.\n❯   prod\n    dev\n    peer\n```\n\n### 3. Upgrade Command\n\n```bash\nvp upgrade react\nvp upgrade --latest\nvp upgrade --interactive\n```\n\n### 4. Smart Suggestions\n\n```bash\n$ vp add react\nAdding react...\n💡 Suggestion: Install @types/react for TypeScript support?\n   Run: vp add -D @types/react\n```\n\n### 5. Dependency Analysis\n\n```bash\n$ vp add react\nAnalyzing dependency impact...\n  Will add:\n    react@18.3.1 (85KB)\n    + scheduler@0.23.0 (5KB)\n  Total size: 90KB\n\nProceed? (Y/n)\n```\n\n## Open Questions\n\n1. **Should we warn about peer dependency conflicts?**\n   - Proposed: Let package manager handle warnings\n   - Can be enhanced later with custom warnings\n\n2. **Should we support version specifiers?**\n   - Proposed: Yes, pass through to package manager\n   - Example: `vp add react@18.2.0`\n\n3. **Should we support scoped package shortcuts?**\n   - Proposed: No special handling, pass through as-is\n   - Example: `vp add @types/react` works naturally\n\n4. **Should we prevent adding to wrong dependency types?**\n   - Proposed: No validation, trust package manager\n   - Package managers handle this well already\n\n5. **How to handle pnpm-specific filter features on yarn/npm?**\n   - Proposed: For wildcards/exclusions on yarn/npm:\n     - Option A: Error with clear message explaining pnpm-only feature\n     - Option B: Resolve wildcard ourselves and run command for each package\n   - Recommendation: Start with Option A, add Option B later\n\n6. **Should we support workspace protocol configuration?**\n   - Proposed: Pass through to pnpm, document in .npmrc for users\n   - Example: `save-workspace-protocol=rolling` in .npmrc\n   - Vite+ doesn't need to handle this explicitly\n\n7. **Should we validate that filtered packages exist?**\n   - Proposed: Let package manager validate\n   - Clearer error messages from native tools\n   - Avoids duplicating workspace parsing logic\n\n## Success Metrics\n\n1. **Adoption**: % of users using `vp add/remove` vs direct package manager\n2. **Error Rate**: Track command failures vs package manager direct usage\n3. **User Feedback**: Survey/issues about command ergonomics\n4. **Performance**: Measure overhead vs direct package manager calls (<100ms target)\n\n## Implementation Timeline\n\n- **Week 1**: Core implementation (command parsing, package manager adapter)\n- **Week 2**: Testing (unit tests, integration tests)\n- **Week 3**: Documentation and examples\n- **Week 4**: Review, polish, and release\n\n## Dependencies\n\n### New Dependencies\n\nNone required - leverages existing:\n\n- `vite_package_manager` - package manager detection\n- `clap` - command parsing\n- Existing task execution infrastructure\n\n### Modified Files\n\n- `crates/vite_task/src/lib.rs` - Add command enum variants\n- `crates/vite_task/src/add.rs` - New file\n- `crates/vite_task/src/remove.rs` - New file\n- `crates/vite_package_manager/src/package_manager.rs` - Add command resolution methods\n- `docs/cli.md` - Documentation updates\n\n## Workspace Feature Implementation Priority\n\n### Phase 1: Core Functionality (MVP)\n\n- ✅ Basic add/remove without filters\n- ✅ Multiple package support\n- ✅ Auto package manager detection\n- ✅ Common flags (-D, -E, -P, -O)\n\n### Phase 2: Workspace Support (pnpm-focused)\n\n- ✅ `--filter <exact-name>` for all package managers\n- ✅ `-w` flag for workspace root\n- ✅ `--workspace` flag for workspace dependencies (pnpm)\n- ✅ Wildcard patterns `*` (pnpm only, error on others)\n- ✅ Scope patterns `@scope/*` (pnpm only)\n\n### Phase 3: Advanced Filters (pnpm-focused)\n\n- Exclusion patterns `!<pattern>` (pnpm only)\n- Dependency selectors `...` (pnpm only)\n- Multiple filter support\n- Graceful degradation for yarn/npm\n\n### Phase 4: Cross-PM Compatibility (optional)\n\n- Wildcard resolution for yarn/npm\n- Run filtered command for each matching package\n- Unified behavior across all package managers\n\n## Real-World Usage Examples\n\n### Monorepo Package Management\n\n```bash\n# Add React to all frontend packages\nvp add react react-dom --filter \"@myorg/app-*\"\n\n# Add testing library to all packages\nvp add -D vitest --filter \"*\"\n\n# Add shared utils to app packages (workspace dependency)\nvp add @myorg/shared-utils --workspace --filter \"@myorg/app-*\"\n\n# Remove deprecated package from all packages\nvp remove moment --filter \"*\"\n\n# Add TypeScript to workspace root (shared config)\nvp add -D typescript @types/node -w\n```\n\n### Development Workflow\n\n```bash\n# Clone new monorepo\ngit clone <repo>\nvp install\n\n# Add new feature dependencies to web app\ncd packages/web\nvp add axios react-query\n\n# Add development tool to specific package\nvp add -D webpack-bundle-analyzer --filter web\n\n# Remove unused dependencies from utils package\nvp rm lodash underscore --filter utils\n\n# Add workspace package as dependency\nvp add @myorg/ui-components --workspace --filter web\n```\n\n### Migration from Direct Package Manager\n\n```bash\n# Before (package manager specific)\npnpm --filter app add react\nyarn workspace app add react\nnpm install react --workspace app\n\n# After (unified)\nvp add react --filter app\n```\n\n## Conclusion\n\nThis RFC proposes adding `vp add` and `vp remove` commands to provide a unified interface for package management across pnpm/yarn/npm. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports multiple packages in single command\n- ✅ **Full workspace support following pnpm's API design**\n- ✅ **Filter patterns for targeting specific packages**\n- ✅ **Workspace root and workspace protocol support**\n- ✅ Uses pass-through for maximum flexibility\n- ✅ No caching overhead (as requested)\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ Graceful degradation for package manager-specific features\n- ✅ Extensible for future enhancements\n\nThe implementation follows pnpm's battle-tested workspace API design while providing graceful degradation for yarn/npm users. This provides immediate value to monorepo developers with a unified, intuitive interface.\n"
  },
  {
    "path": "rfcs/check-command.md",
    "content": "# RFC: `vp check` Command\n\n## Summary\n\nAdd `vp check` as a built-in command that runs format verification, linting, and type checking in a single invocation. This provides a single \"fast check\" command for CI and local development, distinct from \"slow checks\" like test suites.\n\n## Motivation\n\nCurrently, running a full code quality check requires chaining multiple commands:\n\n```bash\n# From the monorepo template's \"ready\" script:\nvp fmt && vp lint --type-aware && vp run test -r && vp run build -r\n```\n\nPain points:\n\n- **No single command** for the most common pre-commit/CI check: \"is my code correct?\"\n- Users must remember to pass `--type-aware` and `--type-check` to lint\n- The `&&` chaining pattern is fragile and verbose\n- No standardized \"check\" workflow across projects\n\n### Fast vs Slow Checks\n\n- **Fast checks** (seconds): type checking + linting + formatting — static analysis, no code execution\n- **Slow checks** (minutes): test suites (Vitest) — code execution\n\n`vp check` targets the **fast checks** category. Tests are explicitly excluded — use `vp test` for that.\n\n## Command Syntax\n\n```bash\n# Run all fast checks (fmt --check + lint --type-aware --type-check)\nvp check\n\n# Auto-fix format and lint issues\nvp check --fix\nvp check --fix --no-lint    # Only fix formatting\n\n# Disable specific checks\nvp check --no-fmt\nvp check --no-lint\nvp check --no-type-aware\nvp check --no-type-check\n```\n\n### Options\n\n| Flag                               | Default | Description                                             |\n| ---------------------------------- | ------- | ------------------------------------------------------- |\n| `--fix`                            | OFF     | Auto-fix format and lint issues                         |\n| `--fmt` / `--no-fmt`               | ON      | Run format check (`vp fmt --check`)                     |\n| `--lint` / `--no-lint`             | ON      | Run lint check (`vp lint`)                              |\n| `--type-aware` / `--no-type-aware` | ON      | Enable type-aware lint rules (oxlint `--type-aware`)    |\n| `--type-check` / `--no-type-check` | ON      | Enable TypeScript type checking (oxlint `--type-check`) |\n\n**Flag dependency:** `--type-check` requires `--type-aware` as a prerequisite.\n\n- `--type-aware` enables lint rules that use type information (e.g., `no-floating-promises`)\n- `--type-check` enables experimental TypeScript compiler-level type checking (requires type-aware)\n- If `--no-type-aware` is set, `--type-check` is also implicitly disabled\n\nBoth are enabled by default in `vp check` to provide comprehensive static analysis.\n\n### File Path Arguments\n\n`vp check` accepts optional trailing file paths, which are passed through to `fmt` and `lint`:\n\n```bash\n# Check only specific files\nvp check --fix src/index.ts src/utils.ts\n```\n\nWhen file paths are provided:\n\n- `--no-error-on-unmatched-pattern` is automatically added to `fmt` args (prevents errors when paths don't match fmt patterns)\n- Paths are appended to both `fmt` and `lint` sub-commands\n\nThis enables lint-staged integration:\n\n```json\n\"lint-staged\": {\n  \"*.@(js|ts|tsx)\": \"vp check --fix\"\n}\n```\n\nlint-staged appends staged file paths automatically, so `vp check --fix` becomes e.g. `vp check --fix src/a.ts src/b.ts`.\n\n## Behavior\n\nCommands run **sequentially** with fail-fast semantics:\n\n```\n1. vp fmt --check                          (verify formatting, don't auto-fix)\n2. vp lint --type-aware --type-check       (lint + type checking)\n```\n\nIf any step fails, `vp check` exits immediately with a non-zero exit code.\n\n## CLI Output\n\n`vp check` should print **completion summaries only** for successful phases:\n\n```text\npass: All 989 files are correctly formatted (423ms, 16 threads)\npass: Found no warnings, lint errors, or type errors in 150 files (452ms, 16 threads)\n```\n\nOutput rules:\n\n- Do not print delegated commands such as `vp fmt --check` or `vp lint --type-aware --type-check`\n- Print one `pass:` line only after a phase completes successfully\n- Mention type checks in the lint success line only when `--type-check` is enabled\n- On failure, print a human-readable `error:` line, then raw diagnostics, then a blank line and a final summary sentence\n- Treat `vp check --no-fmt --no-lint` as an error instead of silent success\n\nRepresentative failure output:\n\n```text\nerror: Formatting issues found\nsrc/index.js\nsteps.json\n\nFound formatting issues in 2 files (105ms, 16 threads). Run `vp check --fix` to fix them.\n```\n\n```text\nerror: Lint or type issues found\n...diagnostics...\n\nFound 3 errors and 1 warning in 2 files (452ms, 16 threads)\n```\n\n## Decisions\n\n### Dual mode: verify and fix\n\nBy default, `vp check` is a **read-only verification** command. It never modifies files:\n\n- `vp fmt --check` reports unformatted files (doesn't auto-format)\n- `vp lint --type-aware --type-check` reports issues (doesn't auto-fix)\n\nThis keeps `vp check` safe for CI and predictable for local dev.\n\nWith `--fix`, `vp check` switches to **auto-fix** mode:\n\n- `vp fmt` auto-formats files\n- `vp lint --fix --type-aware --type-check` auto-fixes lint issues\n\nThis replaces the manual `vp fmt && vp lint --fix` workflow with a single command.\n\n### No tests\n\n`vp check` does **not** run Vitest. The distinction is intentional:\n\n- `vp check` = fast static analysis (seconds)\n- `vp test` = test execution (minutes)\n\n## Implementation Architecture\n\n### Rust Global CLI\n\nAdd `Check` variant to `Commands` enum in `crates/vite_global_cli/src/cli.rs`:\n\n```rust\n#[command(disable_help_flag = true)]\nCheck {\n    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n    args: Vec<String>,\n},\n```\n\nRoute via delegation:\n\n```rust\nCommands::Check { args } => commands::delegate::execute(cwd, \"check\", &args).await,\n```\n\n### NAPI Binding\n\nAdd `Check` to `SynthesizableSubcommand` in `packages/cli/binding/src/cli.rs`. The check command internally resolves and runs fmt + lint sequentially, reusing existing resolvers.\n\n### TypeScript Side\n\nNo new resolver needed — `vp check` reuses existing `resolve-lint.ts` and `resolve-fmt.ts`.\n\n### Key Files to Modify\n\n1. `crates/vite_global_cli/src/cli.rs` — Add `Check` command variant and routing\n2. `packages/cli/binding/src/cli.rs` — Add check subcommand handling (sequential fmt + lint)\n3. `packages/cli/src/bin.ts` — (if needed for routing)\n\n## CLI Help Output\n\n```\nRun format, lint, and type checks\n\nUsage: vp check [OPTIONS]\n\nOptions:\n      --fmt              Run format check [default: true]\n      --lint             Run lint check [default: true]\n      --type-aware       Enable type-aware linting [default: true]\n      --type-check       Enable TypeScript type checking [default: true]\n  -h, --help             Print help\n```\n\n## Relationship to Existing Commands\n\n| Command                             | Purpose                                          | Speed    |\n| ----------------------------------- | ------------------------------------------------ | -------- |\n| `vp fmt`                            | Format code (auto-fix)                           | Fast     |\n| `vp fmt --check`                    | Verify formatting                                | Fast     |\n| `vp lint`                           | Lint code                                        | Fast     |\n| `vp lint --type-aware --type-check` | Lint + full type checking                        | Fast     |\n| `vp test`                           | Run test suite                                   | Slow     |\n| `vp build`                          | Build project                                    | Slow     |\n| **`vp check`**                      | **fmt --check + lint --type-aware --type-check** | **Fast** |\n| **`vp check --fix`**                | **fmt + lint --fix --type-aware --type-check**   | **Fast** |\n\nWith `vp check`, the monorepo template's \"ready\" script simplifies to:\n\n```json\n\"ready\": \"vp check && vp run test -r && vp run build -r\"\n```\n\n## Comparison with Other Tools\n\n| Tool              | Scope                              |\n| ----------------- | ---------------------------------- |\n| `cargo check`     | Type checking only                 |\n| `cargo clippy`    | Lint only                          |\n| **`biome check`** | **Format + lint (closest analog)** |\n| `deno check`      | Type checking only                 |\n\n## Snap Tests\n\n```\npackages/cli/snap-tests/check-basic/\n  package.json\n  steps.json     # { \"steps\": [{ \"command\": \"vp check\" }] }\n  src/index.ts   # Clean file that passes all checks\n  snap.txt\n\npackages/cli/snap-tests/check-fmt-fail/\n  package.json\n  steps.json     # { \"steps\": [{ \"command\": \"vp check\" }] }\n  src/index.ts   # Badly formatted file\n  snap.txt       # Shows fmt --check failure, lint doesn't run (fail-fast)\n\npackages/cli/snap-tests/check-no-fmt/\n  package.json\n  steps.json     # { \"steps\": [{ \"command\": \"vp check --no-fmt\" }] }\n  snap.txt       # Only lint runs\n```\n"
  },
  {
    "path": "rfcs/cli-output-polish.md",
    "content": "# RFC: CLI Output Polish\n\n## Status\n\nDraft\n\n## Executive Summary\n\nVite+ wraps several sub-tools (vite, vitest, oxlint, oxfmt) and has native Rust commands (upgrade, env, vpx, package manager commands). Each sub-tool currently shows its own branding and uses inconsistent formatting for messages, prefixes, and status indicators. This RFC proposes unifying all CLI output under the \"Vite+\" brand identity with consistent message formatting, starting with vite (whose source is cloned locally and directly modifiable) and extending to Rust commands and other sub-tools.\n\n## Motivation\n\n### Current Pain Points\n\n**1. Fragmented branding confuses users**\n\nWhen a user runs `vp dev`, the banner displays:\n\n```\n  VITE v8.0.0-beta.13  ready in 312 ms\n```\n\nWhen they run `vp build`, it shows:\n\n```\n  vite v8.0.0-beta.13 building client environment for production...\n```\n\nNeither identifies the experience as \"Vite+\". Users who installed `vite-plus` see \"VITE\" branding and may not understand the relationship.\n\n**2. Message prefix styles are inconsistent across Rust commands**\n\n| File             | Prefix                         | Example                                          |\n| ---------------- | ------------------------------ | ------------------------------------------------ |\n| `upgrade/mod.rs` | `info: ` (lowercase)           | `info: checking for updates...`                  |\n| `upgrade/mod.rs` | `warn: ` (lowercase)           | `warn: Shim refresh failed (non-fatal): ...`     |\n| `vpx.rs`         | `Error: ` (Title case)         | `Error: vpx requires a command to run`           |\n| `which.rs`       | `error:` (lowercase, bold red) | `error: tool 'foo' not found`                    |\n| `main.rs`        | `Error: ` (Title case)         | `Error: Failed to get current directory`         |\n| `pin.rs`         | `Warning: ` (Title case)       | `Warning: Failed to download Node.js ...`        |\n| `pin.rs`         | `Note: `                       | `Note: Version will be downloaded on first use.` |\n| `dlx.rs`         | `Warning: ` (Title case)       | `Warning: yarn dlx does not support shell mode`  |\n| `dlx.rs`         | `Note: `                       | `Note: yarn@1 does not have dlx command...`      |\n\n**3. Status indicator symbols vary**\n\n| Context          | Success                | Failure              | Warning                 |\n| ---------------- | ---------------------- | -------------------- | ----------------------- |\n| `doctor.rs`      | `✓` (`\\u{2713}`) green | `✗` (`\\u{2717}`) red | `⚠` (`\\u{26A0}`) yellow |\n| `upgrade/mod.rs` | `✔` (`\\u{2714}`) green | —                    | —                       |\n| Task runner      | `✓`                    | `✗`                  | —                       |\n\n**4. Color libraries differ (but this is acceptable)**\n\n| Layer              | Library                 |\n| ------------------ | ----------------------- |\n| Rust (global CLI)  | `owo_colors`            |\n| JS (vite-plus CLI) | `node:util styleText()` |\n| vite               | `picocolors`            |\n\n**5. The `[vite]` logger prefix in vite**\n\nThe logger in `vite/packages/vite/src/node/logger.ts` defaults to `prefix = '[vite]'` for timestamped messages. This shows up during dev server operation as colored `[vite]` tags.\n\n### What Users See Today\n\n```bash\n# Dev server — shows \"VITE\" branding\n$ vp dev\n  VITE v8.0.0-beta.13  ready in 312 ms\n  ➜  Local:   http://localhost:5173/\n\n# Build — shows lowercase \"vite\" branding\n$ vp build\n  vite v8.0.0-beta.13 building client environment for production...\n\n# Upgrade — uses \"info:\" prefix (lowercase)\n$ vp upgrade --check\n  info: checking for updates...\n  info: found vite-plus@0.4.0 (current: 0.3.0)\n\n# vpx — uses \"Error:\" prefix (Title case)\n$ vpx\n  Error: vpx requires a command to run\n```\n\n## Goals\n\n1. Establish a unified branding format where \"VITE+\" is the primary identity shown to users\n2. Standardize message prefix formatting across all commands to a single convention\n3. Standardize status indicator symbols to a single set\n4. Apply branding changes to vite output (dev banner, build banner, logger prefix)\n5. Define a repeatable approach: modify sub-tool source directly to achieve consistent output\n\n## Non-Goals\n\n1. Changing the `VITE_` environment variable prefix (user-facing API, not CLI output)\n2. Changing internal build markers (`__VITE_ASSET__`, `__VITE_PRELOAD__`, etc.)\n3. Changing `vite.config.ts` file names or config API naming\n4. Changing the color library used by each component (each keeps its own)\n5. Rebranding vitest or oxlint in Phase 1 (deferred to later phases)\n\n## Proposed Solution\n\n### Overview: Direct Source Modification\n\nSince vite-plus clones sub-tool source repositories (vite at `vite/`, rolldown at `rolldown/`), we modify the source directly. This is simple, transparent, and easy to audit via `git diff`. When syncing upstream, branding patches are rebased or re-applied — a small, well-defined set of changes.\n\nOther sub-tools (vitest, oxlint, oxfmt) can follow the same pattern once their source is cloned or forked.\n\n### Phase 1: Rebrand vite Output\n\n#### 1.1 Dev server banner\n\n**File:** `vite/packages/vite/src/node/cli.ts` (line 256)\n\n**Current:**\n\n```javascript\ninfo(\n  `\\n  ${colors.green(\n    `${colors.bold('VITE')} v${VERSION}`,\n  )}${modeString}  ${startupDurationString}\\n`,\n  { clear: !hasExistingLogs },\n);\n```\n\n**Output:** `VITE v8.0.0-beta.13  ready in 312 ms`\n\n**Proposed change:**\n\n```javascript\ninfo(\n  `\\n  ${colors.green(\n    `${colors.bold('VITE+')} v${VITE_PLUS_VERSION}`,\n  )}${modeString}  ${startupDurationString}\\n`,\n  { clear: !hasExistingLogs },\n);\n```\n\n**Output:** `VITE+ v0.3.0  ready in 312 ms`\n\nWhere `VITE_PLUS_VERSION` is the vite-plus package version, injected via:\n\n- A new constant in `vite/packages/vite/src/node/constants.ts`, or\n- Read from an environment variable set by the Rust CLI before spawning vite (e.g., `VITE_PLUS_VERSION`)\n\n**Recommended approach:** Environment variable injection. The Rust NAPI binding in `packages/cli/binding/src/cli.rs` already merges environment variables when spawning sub-tools via `merge_resolved_envs()`. We add `VITE_PLUS_VERSION` to the env map, and read it in vite:\n\n```javascript\nconst VITE_PLUS_VERSION = process.env.VITE_PLUS_VERSION || VERSION;\n```\n\nThis is clean: the vite source change is minimal (reads an env var with fallback), and the version injection happens in the Rust layer that already owns this responsibility.\n\n#### 1.2 Build banner\n\n**File:** `vite/packages/vite/src/node/build.ts` (line 789)\n\n**Current:**\n\n```javascript\nlogger.info(\n  colors.blue(\n    `vite v${VERSION} ${colors.green(\n      `building ${environment.name} environment for ${environment.config.mode}...`,\n    )}`,\n  ),\n);\n```\n\n**Output:** `vite v8.0.0-beta.13 building client environment for production...`\n\n**Proposed change:**\n\n```javascript\nlogger.info(\n  colors.blue(\n    `vite+ v${VITE_PLUS_VERSION} ${colors.green(\n      `building ${environment.name} environment for ${environment.config.mode}...`,\n    )}`,\n  ),\n);\n```\n\n**Output:** `vite+ v0.3.0 building client environment for production...`\n\n#### 1.3 Logger prefix\n\n**File:** `vite/packages/vite/src/node/logger.ts` (line 78)\n\n**Current:**\n\n```javascript\nprefix = '[vite]',\n```\n\n**Proposed:**\n\n```javascript\nprefix = '[vite+]',\n```\n\n#### 1.4 Other user-visible strings to audit\n\nA full audit of vite source for user-visible \"vite\" strings:\n\n| Location                      | String                                                                       | Action                                                |\n| ----------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- |\n| `cli.ts:256`                  | `'VITE'` in banner                                                           | Change to `'VITE+'`                                   |\n| `build.ts:789`                | `` `vite v${VERSION}` ``                                                     | Change to `` `vite+ v${VITE_PLUS_VERSION}` ``         |\n| `logger.ts:78`                | `'[vite]'`                                                                   | Change to `'[vite+]'`                                 |\n| `build.ts:674`                | `\"This is deprecated and will override all Vite.js default output options.\"` | Leave — refers to the Vite project name, not branding |\n| `build.ts:680`                | `\"Vite does not support...\"`                                                 | Leave — project name reference                        |\n| `build.ts:1079`               | `\"[vite]: Rolldown failed to resolve...\"`                                    | Change to `\"[vite+]: ...\"`                            |\n| Config error messages         | `\"Vite requires Node.js...\"`                                                 | Leave — project name reference                        |\n| `vite:*` plugin name prefixes | `'vite:esbuild-banner-footer-compat'` etc.                                   | Leave — internal plugin IDs, not user-facing          |\n| `VITE_*` env var detection    | `import.meta.env.VITE_*`                                                     | Leave — user API, not branding                        |\n\n**Principle:** Change branding text that appears in terminal output. Leave references to \"Vite\" as a project/software name in error descriptions, and leave all internal identifiers.\n\n### Phase 2: Standardize Rust CLI Output\n\n#### 2.1 Create a shared output module\n\nAdd formatting functions to a shared location. This could be a new `vite_output` crate or a module within an existing shared crate.\n\n```rust\nuse owo_colors::OwoColorize;\n\n// Standard status symbols\npub const CHECK: &str = \"\\u{2713}\";   // ✓ — success\npub const CROSS: &str = \"\\u{2717}\";   // ✗ — failure\npub const WARN_SIGN: &str = \"\\u{26A0}\"; // ⚠ — warning\npub const ARROW: &str = \"\\u{2192}\";   // → — transitions\n\n/// Print an info message to stderr.\npub fn info(msg: &str) {\n    eprintln!(\"{} {}\", \"info:\".bright_blue().bold(), msg);\n}\n\n/// Print a warning message to stderr.\npub fn warn(msg: &str) {\n    eprintln!(\"{} {}\", \"warn:\".yellow().bold(), msg);\n}\n\n/// Print an error message to stderr.\npub fn error(msg: &str) {\n    eprintln!(\"{} {}\", \"error:\".red().bold(), msg);\n}\n\n/// Print a note message to stderr (supplementary info).\npub fn note(msg: &str) {\n    eprintln!(\"{} {}\", \"note:\".dimmed().bold(), msg);\n}\n\n/// Print a success line with checkmark to stdout.\npub fn success(msg: &str) {\n    println!(\"{} {}\", CHECK.green(), msg);\n}\n```\n\n**Design choice — lowercase prefixes:** Matches the Rust compiler convention (`error[E0308]:`, `warning:`, `note:`). Since vite-plus has a Rust core, aligning with the Rust ecosystem feels natural and is more compact than Title case.\n\n#### 2.2 Standardize symbols\n\nAdopt a single set everywhere:\n\n| Symbol           | Unicode      | Usage           | Color  |\n| ---------------- | ------------ | --------------- | ------ |\n| `✓` (`\\u{2713}`) | Check mark   | Success         | green  |\n| `✗` (`\\u{2717}`) | Ballot X     | Failure         | red    |\n| `⚠` (`\\u{26A0}`) | Warning sign | Warning/caution | yellow |\n| `→` (`\\u{2192}`) | Right arrow  | Transitions     | none   |\n\n**Change:** Replace `\\u{2714}` (heavy check mark ✔) in `upgrade/mod.rs` with `\\u{2713}` (check mark ✓) for consistency with `doctor.rs` and the task runner.\n\n#### 2.3 Migration targets\n\nCommands to update (representative, not exhaustive):\n\n| File                 | Current                               | New                                |\n| -------------------- | ------------------------------------- | ---------------------------------- |\n| `upgrade/mod.rs:58`  | `eprintln!(\"info: checking...\")`      | `output::info(\"checking...\")`      |\n| `upgrade/mod.rs:69`  | `eprintln!(\"info: found...\")`         | `output::info(\"found...\")`         |\n| `upgrade/mod.rs:173` | `eprintln!(\"warn: Shim refresh...\")`  | `output::warn(\"Shim refresh...\")`  |\n| `upgrade/mod.rs:75`  | `\"\\u{2714}\".green()`                  | `output::CHECK.green()`            |\n| `main.rs:75`         | `eprintln!(\"Error: Failed...\")`       | `output::error(\"Failed...\")`       |\n| `main.rs:121`        | `eprintln!(\"Error: {e}\")`             | `output::error(...)`               |\n| `vpx.rs:72`          | `eprintln!(\"Error: vpx requires...\")` | `output::error(\"vpx requires...\")` |\n| `which.rs:40`        | `\"error:\".red().bold()`               | `output::error(...)`               |\n| `pin.rs:142`         | `println!(\"  Note: Version...\")`      | `output::note(\"Version...\")`       |\n| `pin.rs:155`         | `eprintln!(\"Warning: Failed...\")`     | `output::warn(\"Failed...\")`        |\n| `dlx.rs:167`         | `eprintln!(\"Warning: yarn dlx...\")`   | `output::warn(\"yarn dlx...\")`      |\n| `dlx.rs:184`         | `eprintln!(\"Note: yarn@1...\")`        | `output::note(\"yarn@1...\")`        |\n\nThe `vite_install` crate also has `Warning:` and `Note:` messages across multiple command files (`list.rs`, `why.rs`, `outdated.rs`, `pack.rs`, `publish.rs`, `cache.rs`, `config.rs`, `audit.rs`, `dlx.rs`, `unlink.rs`, `update.rs`, `rebuild.rs`, `whoami.rs`). All should be migrated.\n\n### Phase 3: Rebrand vitest Output\n\nVitest is bundled (not cloned source) via `@voidzero-dev/vite-plus-test`. Its build script (`packages/test/build.ts`) copies and rewrites vitest's dist files. We patch the bundled cac chunk during the build to rebrand CLI output.\n\n#### 3.1 Approach: Build-time patching of bundled cac chunk\n\nAfter `bundleVitest()` copies vitest files to `dist/`, a `brandVitest()` step patches the cac chunk (`dist/chunks/cac.*.js`) with string replacements:\n\n1. `cac(\"vitest\")` → `cac(\"vp test\")` — CLI name shown in banner and help output\n2. `var version = \"<semver>\"` → `var version = process.env.VITE_PLUS_VERSION || \"<semver>\"` — runtime version injection via env var\n3. `/^vitest\\/\\d+\\.\\d+\\.\\d+$/` regex → `/^vp test\\/[\\d.]+$/` — so the help callback can still find the banner line\n4. `$ vitest --help --expand-help` → `$ vp test --help --expand-help` — hardcoded help text\n\nThe Rust NAPI binding injects `VITE_PLUS_VERSION` env var (same mechanism used for vite build/dev/preview commands), so `vp test -h` shows `vp test/<vite-plus-version>`.\n\n#### 3.3 Remaining `vite` → `vp` branding in CLI output\n\nSeveral user-visible strings still show `vite` instead of `vp`:\n\n1. **Local CLI help usage line** (`packages/cli/binding/src/cli.rs`): `Usage: vite <COMMAND>` → `Usage: vp <COMMAND>`\n2. **Pack CLI cac name** (`packages/cli/src/pack-bin.ts`): `cac('vp pack')` → `cac('vp pack')`\n3. **Migration message** (`packages/cli/src/migration/bin.ts`): `vp install` → `vp install`\n\nThese are straightforward string replacements in the source, verified by snap test updates.\n\n#### 3.4 Future: oxlint, oxfmt\n\nFor oxlint and oxfmt, pre-spawn banners or build-time patching can follow the same pattern once their source/dist is bundled.\n\n### Phase 3.5: Rebrand tsdown Output\n\ntsdown is bundled via `@voidzero-dev/vite-plus-core`. Its build script (`packages/core/build.ts`) bundles tsdown's dist files via rolldown.\n\n#### 3.5.1 Approach: Build-time patching of bundled build chunk\n\nAfter `bundleTsdown()` rebuilds tsdown, a `brandTsdown()` step patches the build chunk (`dist/tsdown/build-*.js`) with string replacements:\n\n1. `\"tsdown <your-file>\"` → `\"vp pack <your-file>\"` — error message when no input files found\n\nInternal identifiers are left unchanged: debug namespaces (`tsdown:*`), plugin names (`tsdown:external`), config prefix (`tsdown.config`), temp dirs (`tsdown-pack-`).\n\n### Phase 4: JS-Side Output Consistency\n\nThe JS code in `packages/cli/src/utils/terminal.ts` already has `accent()`, `headline()`, `muted()`, `success()`, `error()` functions. Extend it with prefix functions matching the Rust convention:\n\n```typescript\nexport function info(msg: string) {\n  console.error(styleText(['blue', 'bold'], 'info:'), msg);\n}\n\nexport function warn(msg: string) {\n  console.error(styleText(['yellow', 'bold'], 'warn:'), msg);\n}\n\nexport function errorMsg(msg: string) {\n  console.error(styleText(['red', 'bold'], 'error:'), msg);\n}\n\nexport function note(msg: string) {\n  console.error(styleText(['gray', 'bold'], 'note:'), msg);\n}\n```\n\nMigrate JS-side code (`migration/bin.ts`, `create/bin.ts`) to use these shared functions where they currently use ad-hoc formatting.\n\n## Design Decisions\n\n### D1: Direct source modification over build-time transforms\n\n**Decision:** Modify vite source files directly.\n\n**Rationale:** The user has the source cloned locally. Direct modification is transparent — anyone can `git diff vite/` to see exactly what changed. The set of branding changes is small and well-defined (3-5 files), making rebasing during upstream sync manageable. Build-time transforms (Rolldown plugins in `packages/core/build.ts`) are an alternative that avoids merge conflicts, but they are less visible and can break silently when upstream changes the strings being matched.\n\n### D2: Only show vite-plus version, not underlying vite version\n\n**Decision:** Banner shows `VITE+ v0.3.0`, not `VITE+ v0.3.0 (vite 8.0.0-beta.13)`.\n\n**Rationale:** Cleaner output. The underlying vite version is still available via `vp --version` which shows a detailed version table. The banner should communicate identity, not debug information.\n\n### D3: Inject version via environment variable\n\n**Decision:** The Rust CLI sets `VITE_PLUS_VERSION` env var before spawning vite. The modified vite source reads it with a fallback.\n\n**Rationale:** This avoids hardcoding the version in vite source (which would require updating on every release). The Rust CLI already manages environment variables for sub-tool spawning via `merge_resolved_envs()`. The env var approach is the minimal-touch change to vite.\n\n### D4: Lowercase prefixes (`info:` not `Info:`)\n\n**Decision:** All prefixes are lowercase with bold coloring: `info:`, `warn:`, `error:`, `note:`.\n\n**Rationale:** Matches the Rust compiler convention. Compact and consistent. The current codebase is split between lowercase (`info:` in upgrade.rs) and Title case (`Warning:` in vpx.rs) — picking one convention eliminates the inconsistency.\n\n### D5: Pre-spawn banners for sub-tools we don't control\n\n**Decision:** Print a single `vite+ v0.3.0 — <command>` line before spawning vitest/oxlint/oxfmt.\n\n**Rationale:** Parsing or wrapping sub-tool stdout/stderr is fragile and can break ANSI colors, progress indicators, and interactive output. A single leading line is non-intrusive. Long-term, these sub-tools should be directly modified once their source is cloned.\n\n### D6: Keep each layer's color library\n\n**Decision:** Rust keeps `owo_colors`, JS keeps `node:util styleText()`, vite keeps `picocolors`.\n\n**Rationale:** Changing color libraries is high-risk, low-reward. The shared formatting module abstracts the library choice so the output convention is consistent regardless of the underlying library.\n\n## Scope of vite Changes\n\n### Strings to Change\n\nThese are user-visible branding strings that appear in terminal output:\n\n1. **`cli.ts:256`** — Dev server banner: `'VITE'` → `'VITE+'`, `VERSION` → `VITE_PLUS_VERSION`\n2. **`build.ts:789`** — Build banner: `` `vite v${VERSION}` `` → `` `vite+ v${VITE_PLUS_VERSION}` ``\n3. **`logger.ts:78`** — Logger prefix: `'[vite]'` → `'[vite+]'`\n4. **`build.ts:1079`** — Error message prefix: `'[vite]:'` → `'[vite+]:'`\n\n### Strings to Leave Unchanged\n\nThese are internal identifiers, API references, or project name references:\n\n- `VITE_` environment variable prefix and detection\n- `VITE_PACKAGE_DIR`, `CLIENT_ENTRY`, `ENV_ENTRY` constant names\n- `__VITE_ASSET__`, `__VITE_PRELOAD__` internal build markers\n- `vite:*` plugin name prefixes (`vite:esbuild-banner-footer-compat`, etc.)\n- `vite.config.ts`, `vite.config.js` file detection\n- Error messages that reference \"Vite\" as a project name (e.g., `\"Vite does not support...\"`)\n- `import.meta.env.VITE_*` documentation and detection\n- `.vite/` cache directory name\n\n## Implementation Plan\n\n### Phase 1: vite Rebranding\n\n1. Add `VITE_PLUS_VERSION` env var injection in `packages/cli/binding/src/cli.rs` for vite commands (build, dev, preview)\n2. Modify `vite/packages/vite/src/node/cli.ts` — read env var, change banner text\n3. Modify `vite/packages/vite/src/node/build.ts` — change build banner text\n4. Modify `vite/packages/vite/src/node/logger.ts` — change default prefix\n5. Modify `vite/packages/vite/src/node/build.ts:1079` — change error prefix\n6. Rebuild with `pnpm bootstrap-cli` and verify output\n7. Update affected snap tests\n\n### Phase 2: Rust CLI Output Standardization\n\n1. Create shared output module with `info()`, `warn()`, `error()`, `note()`, `success()` and symbol constants\n2. Add as dependency to `vite_global_cli` and `vite_install`\n3. Migrate `upgrade/mod.rs` (6 message sites)\n4. Migrate `main.rs` error handling (3 sites)\n5. Migrate `vpx.rs` (4 sites)\n6. Migrate `env/which.rs` (3 sites)\n7. Migrate `env/pin.rs` (3 sites)\n8. Migrate `vite_install/src/commands/*.rs` Warning/Note messages\n9. Update snap tests\n\n### Phase 2.5: tsdown Branding\n\n1. Add `brandTsdown()` in `packages/core/build.ts` after `bundleTsdown()`\n2. Patch `dist/tsdown/build-*.js` with string replacement: `\"tsdown <your-file>\"` → `\"vp pack <your-file>\"`\n3. Update snap tests\n\n### Phase 3: Sub-tool Banners\n\n1. Add `print_banner()` for vitest, oxlint, oxfmt in `packages/cli/binding/src/cli.rs`\n2. Gate on TTY check (skip in piped output)\n3. Update snap tests\n\n### Phase 4: JS Output Consistency\n\n1. Add prefix functions to `packages/cli/src/utils/terminal.ts`\n2. Migrate `migration/bin.ts` and `create/bin.ts` to use shared functions\n3. Update snap tests\n\n## Testing Strategy\n\n### Snap Tests\n\nMany existing snap tests will need updates due to prefix and branding changes:\n\n- `snap-tests-global/command-upgrade-check/snap.txt` — `info:` prefix format\n- `snap-tests-global/command-upgrade-rollback/snap.txt` — success format\n- `snap-tests-global/command-env-which/snap.txt` — error format\n- `snap-tests/command-dev-*/snap.txt` — vite banner change\n- `snap-tests/command-build-*/snap.txt` — build banner change\n- All `Warning:`/`Note:` snap outputs across global snap tests\n- `snap-tests/command-pack-no-input/snap.txt` — tsdown error message branding\n\n**Process:** Run `pnpm -F vite-plus snap-test` after each phase, review `git diff` on `snap.txt` files, and verify the new formatting matches expectations.\n\n### Manual Verification\n\n- `vp dev` shows `VITE+ v<version>  ready in X ms`\n- `vp build` shows `vite+ v<version> building ...`\n- `vp upgrade --check` shows `info: checking for updates...`\n- `vp env doctor` shows consistent ✓/✗/⚠ symbols\n- `vpx` (no args) shows `error: vpx requires a command to run`\n- Piped output (`vp dev | cat`) does not show sub-tool banners\n\n### CI\n\n- All existing `cargo test` and snap tests pass with updated expectations\n- No regressions in vite's own test suite\n\n## Future Enhancements\n\n- Clone oxlint/oxfmt source for `vp lint` / `vp fmt` branding (or apply build-time patching)\n- Unified progress indicator style (spinner, progress bar) across long-running operations\n- Structured JSON output mode (`--json`) for machine-readable output across all commands\n"
  },
  {
    "path": "rfcs/cli-tips.md",
    "content": "# CLI Tips\n\n## Background\n\nAs vite-plus grows in features, users often don't discover useful commands, shorter aliases, or relevant options. A lightweight, non-intrusive tip system helps users learn the tool organically.\n\n## Implementation\n\n### Crate Structure\n\nTips are implemented in `crates/vite_global_cli_tips/`:\n\n```\nsrc/\n├── lib.rs          # TipContext, Tip trait, get_tip()\n└── tips/\n    ├── mod.rs              # Registers all tips\n    ├── short_aliases.rs    # Short alias suggestions\n    └── use_vpx_or_run.rs   # Unknown command guidance (disabled)\n```\n\n### Core Types\n\n```rust\n/// Execution context passed to tips\npub struct TipContext {\n    pub raw_args: Vec<String>,    // CLI args (excluding program name)\n    pub success: bool,            // Command succeeded\n    pub unknown_command: bool,    // Command not recognized by CLI\n}\n\n/// Trait for implementing tips\npub trait Tip {\n    fn matches(&self, ctx: &TipContext) -> bool;\n    fn message(&self) -> &'static str;\n}\n```\n\n### Display\n\n- Tips shown after command output with empty line separator\n- Styled with dimmed text using `owo-colors`\n- Rate limited: tips display once per 5 minutes (stateless, based on wall clock)\n- Disabled in test mode (`VITE_PLUS_CLI_TEST` env var)\n\n```\n$ vp list\n...\n\nTip: short aliases available: i (install), rm (remove), un (uninstall), up (update), ls (list), ln (link)\n```\n\n### Current Tips\n\n#### ShortAliases\n\nShown when user runs long-form package manager commands (`install`, `remove`, `uninstall`, `update`, `list`, `link`).\n\n```\nTip: short aliases available: i (install), rm (remove), un (uninstall), up (update), ls (list), ln (link)\n```\n\n#### UseVpxOrRun (disabled)\n\nTODO: Enable when `vpx` is supported. Will show for unknown commands.\n\n```\nTip: run a local bin with `vpx <bin>`, or a script with `vp run <script>`\n```\n\n### Adding a New Tip\n\n1. Create `src/tips/my_tip.rs`:\n\n```rust\nuse crate::{Tip, TipContext};\n\npub struct MyTip;\n\nimpl Tip for MyTip {\n    fn matches(&self, ctx: &TipContext) -> bool {\n        // Return true when tip should be shown\n        ctx.is_subcommand(\"some-command\")\n    }\n\n    fn message(&self) -> &'static str {\n        \"Your tip message here\"\n    }\n}\n```\n\n2. Register in `src/tips/mod.rs`:\n\n```rust\nmod my_tip;\nuse self::my_tip::MyTip;\n\npub fn all() -> &'static [&'static dyn Tip] {\n    &[&ShortAliases, &UseVpxOrRun, &MyTip]\n}\n```\n\n## Future Work\n\n- Tip frequency control (random probability, cooldown)\n- More contextual tips (feature discovery, guidance)\n- AI agent integration (extract tips for AGENTS.md)\n"
  },
  {
    "path": "rfcs/code-generator.md",
    "content": "# RFC: Vite+ Code Generator\n\n## Background\n\nReference:\n\n- [Nx Code Generation](https://nx.dev/docs/features/generate-code)\n- [Turborepo Code Generation](https://turborepo.com/docs/guides/generating-code)\n- [Bingo Framework](https://www.create.bingo/about)\n\nThe code generator function is an essential feature for a company's continuously iterated monorepo. Without it, creating a new project can only be done by manually copying and pasting and then modifying the files to make them usable, which likely leads to missing changes.\n\n## Comparison of Existing Solutions\n\n| Feature                      | Nx                                        | Turbo                                        | Bingo                                                                               |\n| ---------------------------- | ----------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------- |\n| Generator approach           | Nx plugin's generator feature             | Based on [PLOP](https://plopjs.com/) wrapper | Repository templating engine with type-safe options                                 |\n| Monorepo reuse               | ✅                                        | ✅                                           | ✅                                                                                  |\n| Template engine              | [EJS](https://github.com/nrwl/nx)         | Handlebars                                   | [Template Literals](https://www.create.bingo/build/concepts/templates) / Handlebars |\n| Input validation             | schema.json                               | validate(input): true \\| string              | Zod schemas (type-safe)                                                             |\n| Advanced features            | Tree API for file operations              | Medium                                       | Network requests, shell scripts, blocks/presets                                     |\n| Complexity of implementation | High (through @nx/devkit tree operations) | Medium                                       | Medium (TypeScript-based)                                                           |\n| Type safety                  | Medium (JSON Schema)                      | Low (JavaScript)                             | High (Zod + TypeScript)                                                             |\n\n## Why Bingo's Approach?\n\nBingo offers several advantages:\n\n1. **Type Safety**: Uses Zod schemas with TypeScript for compile-time validation\n2. **Modern Architecture**: Built from the ground up with modern JavaScript/TypeScript\n3. **Flexible**: Supports simple templates to complex block-based systems\n4. **Extensible**: Can execute network requests and shell scripts beyond file generation\n5. **Two Modes**: Setup (create new) and Transition (update existing) modes\n6. **Testable**: Built-in testing utilities for template development\n7. **Existing Ecosystem**: Can directly run existing bingo templates from npm\n\n## Integration Strategy: Dual-Mode Support\n\n**Key Decision**: `vp create` supports BOTH bingo templates AND any existing `create-*` template through intelligent migration:\n\n### Two Ways to Write Generators\n\n**Option 1: Bingo Templates (Recommended for Custom Generators)**\n\nBest for creating **reusable local generators** within your monorepo:\n\n```bash\n# Run workspace-local bingo generator\nvp create @company/generator-ui-lib\n\n# Or any bingo template from npm\nvp create create-typescript-app\n```\n\n**Why use bingo**:\n\n- ✅ **Easier to write**: Type-safe with Zod schemas\n- ✅ **Better DX**: Full control over file generation\n- ✅ **Testable**: Built-in testing utilities\n- ✅ **Quick start**: Use `@vite-plus/create-generator` to scaffold a generator\n- ✅ **Perfect for**: Company-specific patterns and standards\n\n**Option 2: Universal Templates (Any create-\\* package)**\n\nRun **ANY** existing `create-*` template from the ecosystem:\n\n```bash\n# Run any existing template\nvp create create-vite\nvp create create-next-app\nvp create create-nuxt\n```\n\n**Why use universal templates**:\n\n- ✅ **No work required**: Use existing templates as-is\n- ✅ **Zero learning curve**: Use familiar templates\n- ✅ **Huge ecosystem**: Thousands of templates available\n- ✅ **No maintenance**: Template authors maintain them\n\n### Auto-Migration for ALL Templates\n\n**Important**: Regardless of whether you use bingo or universal templates, **all generated code goes through the same auto-detect and migrate logic**:\n\n```\nANY template (bingo or universal)\n  ↓\nTemplate generates code\n  ↓\nVite+ auto-detects vite-related tools:\n  • Standalone vite, vitest, oxlint, oxfmt\n  ↓\nAuto-migrate to unified vite-plus:\n  • Dependencies: vite + vitest + oxlint + oxfmt → vite-plus\n  • Configs: Merge vitest.config.ts, .oxlintrc, .oxfmtrc → vite.config.ts\n  ↓\nMonorepo integration:\n  • Prompt for workspace dependencies\n  • Update workspace config (pnpm-workspace.yaml, package.json, etc.)\n```\n\n**Scope of Auto-Migration**:\n\n- ✅ Consolidate vite/vitest/oxlint/oxfmt dependencies → vite-plus\n- ✅ Merge tool configurations into vite.config.ts\n- ❌ Does NOT migrate ESLint → oxlint (if template uses ESLint, it stays)\n- ❌ Does NOT create vite-task.json (optional, separate feature)\n- ❌ Does NOT change TypeScript config (remains as generated)\n\n**Bingo templates get the same migration treatment** - the difference is just that bingo makes it easier to write generators with type safety and testing.\n\n### How It Works\n\n```\n┌──────────────────────────┐\n│  vp create <name>         │\n└───────────┬──────────────┘\n            │\n      ┌─────▼─────────┐\n      │ Detect & Load │\n      │ Template Type │\n      └─────┬─────────┘\n            │\n    ┌───────┴────────┐\n    │                │\n┌───▼──────┐   ┌─────▼──────┐\n│  Bingo   │   │ Universal  │\n│ Template │   │ (create-*) │\n└───┬──────┘   └─────┬──────┘\n    │                │\n    └────────┬───────┘\n             │\n      ┌──────▼─────────┐\n      │ Execute        │\n      │ Template       │\n      └──────┬─────────┘\n             │\n      ┌──────▼─────────────┐\n      │ Auto-Detect        │\n      │ Generated Code     │\n      │ (ALL templates)    │\n      └──────┬─────────────┘\n             │\n      ┌──────▼─────────────┐\n      │ Auto-Migrate       │\n      │ vite-tools         │\n      │ → vite-plus        │\n      └──────┬─────────────┘\n             │\n      ┌──────▼─────────────┐\n      │ Monorepo           │\n      │ Integration        │\n      │ • Workspace deps   │\n      │ • Update configs   │\n      └────────────────────┘\n```\n\n## Monorepo-Specific Enhancements\n\nAfter any template runs, Vite+ adds monorepo-specific features:\n\n### 1. Auto-Migration to vite-plus Unified Toolchain (for ALL templates)\n\n**After any template runs** (bingo or universal), Vite+ automatically detects standalone vite-related tools and offers to consolidate them into the unified vite-plus dependency.\n\n**Purpose**: Simplify dependency management by consolidating vite, vitest, oxlint, and oxfmt into a single vite-plus package.\n\n```bash\n$ vp create create-vite --template react-ts\n\n# create-vite runs normally...\n✔ Project name: › my-app\n✔ Select a framework: › React\n✔ Select a variant: › TypeScript\n\nScaffolding project in ./packages/my-app...\n\n# After template completes, Vite+ detects standalone tools\n◇  Template completed! Detecting vite-related tools...\n│\n◆  Detected standalone vite tools:\n│  ✓ vite ^5.0.0\n│  ✓ vitest ^1.0.0\n│\n◆  Upgrade to vite-plus unified toolchain?\n│\n│  This will:\n│  • Replace vite + vitest dependencies → single vite-plus dependency\n│  • Merge vitest.config.ts → vite.config.ts (test section)\n│  • Remove vitest.config.ts\n│\n│  Benefits:\n│  • Simplified dependency management (1 instead of 2+ dependencies)\n│  • Unified configuration in vite.config.ts\n│  • Better integration with Vite+ task runner and caching\n│\n│  ● Yes / ○ No\n│\n◇  Migrating to vite-plus...\n│  ✓ Updated package.json (vite + vitest → vite-plus)\n│  ✓ Merged vitest.config.ts → vite.config.ts\n│  ✓ Removed vitest.config.ts\n│\n└  Migration completed!\n```\n\n**Scope of Auto-Migration**:\n\nThis is a **dependency consolidation** feature, not a tool replacement feature.\n\n✅ **What it does**:\n\n- Consolidate standalone vite/vitest/oxlint/oxfmt dependencies → single vite-plus dependency\n- Merge vitest.config.ts → vite.config.ts (test section)\n- Merge .oxlintrc → vite.config.ts (oxlint section)\n- Merge .oxfmtrc → vite.config.ts (oxfmt section)\n- Remove redundant standalone config files\n\n❌ **What it does NOT do**:\n\n- Does NOT migrate ESLint → oxlint (different tools, not consolidation)\n- Does NOT migrate Prettier → oxfmt (different tools, not consolidation)\n- Does NOT create vite-task.json (separate feature, not required)\n- Does NOT change TypeScript configuration (remains as generated)\n- Does NOT modify build tools (webpack/rollup → vite)\n\n**Why this design**:\n\n- Templates that use vite/vitest/oxlint/oxfmt can be simplified to use vite-plus\n- Templates that use other tools (ESLint, Prettier, Jest) remain unchanged\n- Users keep their chosen tools, just with optimized vite-related dependencies\n\n**Migration Engine powered by [ast-grep](https://ast-grep.github.io/)**:\n\n- Structural search and replace for accurate code transformation\n- YAML-based rules for easy maintenance\n- Safe, reversible transformations\n- **Note**: Uses the same migration engine as `vp migrate` command (see [migration-command.md](./migration-command.md))\n\n### 2. Target Directory Selection (Monorepo)\n\nWhen running `vp create` in a monorepo workspace, Vite+ prompts users to select which parent directory to create the new package in:\n\n```bash\n$ vp create create-vite\n\n◆  Where should we create the new package?\n│  ○ apps/        (Applications)\n│  ● packages/    (Shared packages)\n│  ○ services/    (Backend services)\n│  ○ tools/       (Development tools)\n│\n◇  Selected: packages/\n│\n# Template runs...\n✔ Project name: › my-lib\n```\n\n**How it works**:\n\n- Detects package manager from lock files (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lockb)\n- Reads workspace configuration:\n  - **pnpm**: Read `pnpm-workspace.yaml` → `packages` field\n  - **npm/yarn**: Read root `package.json` → `workspaces` field\n  - **bun**: Read root `package.json` → `workspaces` field\n- Extracts parent directories from patterns (e.g., `apps/*`, `packages/*`)\n- Prompts user to select one\n- Passes the selected directory to the template (if template supports directory options)\n- Or changes working directory before running template\n\n**Benefits**:\n\n- Clear organization in monorepo\n- Users don't need to remember directory structure\n- Consistent with workspace organization\n- Can be skipped with `--directory` flag: `vp create create-vite --directory=packages`\n\n### 3. Workspace Dependency Prompts\n\nInspired by [Turbo's generator](https://turborepo.com/docs/guides/generating-code), Vite+ prompts users to select existing workspace packages as dependencies:\n\n```bash\n$ vp create @company/generator-ui-lib --name=design-system\n\n◇ Library name: design-system\n◇ Framework: React\n◇ Include Storybook? Yes\n\n◆ Add workspace packages as dependencies?\n│  ◼ @company/theme\n│  ◼ @company/utils\n│  ◻ @company/icons\n│  ◻ @company/hooks\n└\n\n✅ Created design-system with dependencies:\n   - @company/theme@workspace:*\n   - @company/utils@workspace:*\n```\n\nThis feature:\n\n- **Auto-discovers** all packages in the workspace\n- **Interactive selection** with multi-select checkbox UI\n- **Smart defaults** based on package type or naming conventions\n- **Proper version ranges** using `workspace:*` protocol\n- **Updates package.json** automatically after generation\n\n## Core Concepts\n\n### 1. Templates\n\nA template describes how to initialize or modify a repository given a set of options.\n\n```typescript\nimport { createTemplate } from 'bingo';\nimport { z } from 'zod';\n\nexport default createTemplate({\n  // Define options using Zod schemas for type safety\n  options: {\n    name: z.string().describe('Package name'),\n    directory: z.enum(['apps', 'packages']).default('packages'),\n    framework: z.enum(['react', 'vue', 'svelte']).default('react'),\n  },\n\n  // Optional: Prepare default values\n  async prepare({ fs, options }) {\n    return {\n      name: options.name || (await fs.readdir('.').then((d) => d[0])),\n    };\n  },\n\n  // Core production function\n  async produce({ options }) {\n    const projectPath = `${options.directory}/${options.name}`;\n\n    return {\n      files: {\n        [`${projectPath}/package.json`]: JSON.stringify(\n          {\n            name: options.name,\n            version: '0.0.1',\n            dependencies: {\n              [options.framework]: 'latest',\n            },\n          },\n          null,\n          2,\n        ),\n        [`${projectPath}/src/index.ts`]: `export const app = '${options.name}';`,\n      },\n      scripts: [\n        { phase: 0, commands: [`cd ${projectPath}`, 'vp install'] },\n        { phase: 1, commands: ['vp build'] },\n      ],\n      suggestions: [\n        `✅ Created ${options.name} in ${projectPath}`,\n        `Next: cd ${projectPath} && vp dev`,\n      ],\n    };\n  },\n\n  // Setup mode: additional logic for new repositories\n  async setup({ options }) {\n    return {\n      requests: [\n        {\n          url: 'https://api.github.com/repos/:owner/:repo/labels',\n          method: 'POST',\n          body: { name: 'vite-plus', color: 'ff6b6b' },\n        },\n      ],\n    };\n  },\n\n  // Transition mode: logic for updating existing repositories\n  async transition({ options }) {\n    return {\n      scripts: [\n        {\n          phase: 0,\n          commands: ['rm -rf old-config'],\n          silent: true, // Don't error if file doesn't exist\n        },\n      ],\n    };\n  },\n});\n```\n\n### 2. Creations\n\nA Creation is an in-memory representation of repository changes that templates produce.\n\n**Structure:**\n\n```typescript\ninterface Creation {\n  // Direct changes (always applied)\n  files?: Files; // Hierarchical file structure\n  requests?: Request[]; // Network API calls\n  scripts?: Script[]; // Shell commands\n\n  // Indirect guidance\n  suggestions?: string[]; // Tips for manual steps\n}\n```\n\n**Files Format:**\n\n```typescript\n// Strings become files, objects become directories\nconst files = {\n  'README.md': '# My App',\n  src: {\n    'index.ts': 'export {}',\n    utils: {\n      'helpers.ts': 'export const helper = () => {}',\n    },\n  },\n};\n```\n\n**Scripts with Phases:**\n\n```typescript\nconst scripts = [\n  // Phase 0: runs first\n  { phase: 0, commands: ['vp install'] },\n  { phase: 0, commands: ['git init'] },\n\n  // Phase 1: runs after phase 0 completes\n  { phase: 1, commands: ['vp build'] },\n  { phase: 1, commands: ['vp fmt'] },\n];\n```\n\n### 3. Modes\n\nTemplates operate in two modes:\n\n**Setup Mode**: Creates a brand new repository\n\n- Runs `setup()` function for additional creations\n- Creates GitHub repository (if configured)\n- Initializes git with initial commit\n\n**Transition Mode**: Updates an existing repository\n\n- Runs `transition()` function for migration logic\n- Optionally cleans up old files\n- Preserves existing git history\n\nThe CLI automatically infers the correct mode based on whether running in an existing repo.\n\n### 4. Blocks and Presets\n\nFor complex templates with many configurable features, use the Stratum engine:\n\n```typescript\nimport { createStratumTemplate } from 'bingo-stratum';\n\nexport default createStratumTemplate({\n  // Define blocks (individual features)\n  blocks: {\n    linting: createBlock({\n      about: { name: 'ESLint Configuration' },\n      produce: ({ options }) => ({\n        files: {\n          '.eslintrc.json': JSON.stringify({ extends: ['eslint:recommended'] }),\n        },\n      }),\n    }),\n\n    testing: createBlock({\n      about: { name: 'Vitest Setup' },\n      produce: ({ options }) => ({\n        files: {\n          'vitest.config.ts': 'export default {}',\n        },\n      }),\n    }),\n  },\n\n  // Define presets (block combinations)\n  presets: {\n    minimal: { blocks: [] },\n    common: { blocks: ['linting'] },\n    everything: { blocks: ['linting', 'testing'] },\n  },\n\n  // Suggested default\n  suggested: 'common',\n});\n```\n\n### 5. Inputs\n\nInputs are composable units for data retrieval and processing:\n\n```typescript\nimport { createInput } from 'bingo';\n\nconst readPackageJson = createInput({\n  async produce({ fs }) {\n    const content = await fs.readFile('package.json', 'utf-8');\n    return JSON.parse(content);\n  },\n});\n\nconst detectFramework = createInput({\n  async produce({ take }) {\n    const pkg = await take(readPackageJson);\n\n    if (pkg.dependencies?.react) return 'react';\n    if (pkg.dependencies?.vue) return 'vue';\n    if (pkg.dependencies?.svelte) return 'svelte';\n\n    return 'vanilla';\n  },\n});\n```\n\n## Interactive Mode\n\nWhen running `vp create` without specifying a template, users enter interactive mode with a beautiful template selection interface.\n\n### Template Selection Menu\n\nInteractive mode presents a curated list of known templates:\n\n```bash\n$ vp create\n\n┌  🎨 Vite+ Code Generator\n│\n◆  Which template would you like to use?\n│  ○ Vite+ Monorepo (Create a new Vite+ monorepo project)\n│  ○ Vite+ Generator (Scaffold a new code generator)\n│  ○ Vite (Create vite applications and libraries)\n│  ○ TanStack Start (Create TanStack applications and libraries)\n│  ● Other (Enter a custom template package name)\n└\n```\n\n### Known Templates with Auto-Configuration\n\nInteractive mode includes pre-configured templates with automatic argument injection:\n\n| Template Option     | Built-in Alias           | Description                                |\n| ------------------- | ------------------------ | ------------------------------------------ |\n| **Vite+ Monorepo**  | `vite:monorepo`          | Create a new Vite+ monorepo project        |\n| **Vite+ Generator** | `vite:generator`         | Scaffold a new code generator              |\n| **Vite**            | `create-vite`            | Create vite applications and libraries     |\n| **TanStack Start**  | `@tanstack/create-start` | Create TanStack applications and libraries |\n| **Other**           | _(user input)_           | Custom template package name               |\n\n### Custom Template Input\n\nWhen selecting \"Other\", users can input any npm template:\n\n```bash\n◆  Which template would you like to use?\n│  ● Other (Enter a custom template package name)\n│\n◇  Enter the template package name:\n│  create-next-app\n│\n◇  Discovering template: create-next-app\n...\n```\n\n### Benefits\n\n- **Discoverability**: Users can explore available templates without documentation\n- **Ease of Use**: No need to remember exact template names or arguments\n- **Guided Experience**: Clear hints help users choose the right template\n- **Flexibility**: \"Other\" option allows any npm template\n- **Consistency**: Same post-processing (migration, monorepo integration) applies to all\n\n## CLI Usage\n\n```bash\n# Interactive mode - prompts for template selection\nvp create\n\n# Built-in Vite+ templates\nvp create vite:monorepo                               # Vite+ monorepo\nvp create vite:generator                              # Vite+ generator scaffold\nvp create vite:application                            # Vite+ application\nvp create vite:library                                # Vite+ library\n\n# Run known templates directly\nvp create create-vite                                 # Vite apps/libs\nvp create @tanstack/create-start                      # TanStack apps/libs\n\n# Run ANY template from npm\nvp create create-next-app          # Next.js\nvp create create-nuxt              # Nuxt\nvp create create-typescript-app    # TypeScript (bingo)\nvp create @company/generator-api   # Workspace-local bingo generator\n\n# Run built-in Vite+ generators\nvp create vite:monorepo\nvp create vite:generator\nvp create vite:application\nvp create vite:library\n\n# Pass through template options (use -- separator)\nvp create create-vite -- --template react-ts\nvp create create-next-app -- --typescript --app\n\n# Control migrations (Vite+ options, before --)\nvp create create-vite --no-migrate                    # Skip all migrations\nvp create create-vite --migrate=vite-plus             # Only migrate to vite-plus\n\n# Control target directory (Vite+ options, before --)\nvp create create-vite --directory=packages            # Skip directory selection\n\n# Control workspace dependencies (Vite+ options, before --)\nvp create create-vite --deps=@company/utils,@company/logger  # Pre-select\nvp create create-vite --no-prompt                     # Skip workspace dependency prompt\n\n# Combine Vite+ options and template options\nvp create create-vite --directory=apps --no-migrate --deps=@company/utils -- --template react-ts\n\n# List available templates\nvp create --list               # Shows built-in and popular templates\nvp create --list --all         # Shows all installed templates\n\n# Dry run (show what would be generated/migrated)\nvp create create-vite --dry-run\n\n# Combine with template options\nvp create create-vite --dry-run -- --template vue-ts\n\n# Help\nvp create --help\n\n# Aliases\nvite g\nvp createerate\n```\n\n## @vite-plus/create-generator Scaffold\n\nTo make it easier for users to create custom generators, we provide `@vite-plus/create-generator` - a bingo template that scaffolds a complete generator package.\n\n### What It Generates\n\n```\ntools/generators/{generator-name}/\n├── package.json              # Pre-configured with bingo, zod, bin entry\n├── bin/\n│   └── index.js              # CLI entrypoint\n├── src/\n│   ├── template.ts           # Main template with example code\n│   └── template.test.ts      # Test examples using bingo/testers\n├── tsconfig.json             # TypeScript configuration\n└── README.md                 # Usage and customization guide\n```\n\n### Scaffold Template\n\n```typescript\n// Generated src/template.ts includes helpful examples and comments\nimport { createTemplate } from 'bingo';\nimport { z } from 'zod';\n\nexport default createTemplate({\n  about: {\n    name: '{Generator Name}',\n    description: '{Description}',\n  },\n\n  // TODO: Define your options using Zod schemas\n  options: {\n    name: z.string().describe('Package name'),\n    // Add more options as needed\n  },\n\n  // TODO: Customize the file generation logic\n  async produce({ options }) {\n    return {\n      files: {\n        // Define files to generate\n        [`{output-path}/package.json`]: JSON.stringify(\n          {\n            name: options.name,\n            version: '0.1.0',\n          },\n          null,\n          2,\n        ),\n      },\n      scripts: [\n        // Optional: Add scripts to run after generation\n      ],\n      suggestions: [\n        // Optional: Add suggestions for users\n        `✅ Created ${options.name}`,\n      ],\n    };\n  },\n});\n```\n\n### Usage\n\n```bash\n# Step 1: Create the generator scaffold\nvp create @vite-plus/create-generator\n\n# Step 2: Customize the template\ncd tools/generators/your-generator\n# Edit src/template.ts\n\n# Step 3: Test your generator\nvp create @company/your-generator\n\n# Step 4: Run tests\nvp test\n```\n\n### Benefits\n\nThe scaffold saves you from:\n\n- ✅ Setting up package.json with correct bin entry\n- ✅ Configuring TypeScript for the generator\n- ✅ Writing boilerplate bingo template code\n- ✅ Setting up test infrastructure\n- ✅ Creating README documentation\n\nYou get:\n\n- ✅ Working example template with comments\n- ✅ Complete test setup\n- ✅ TypeScript configuration\n- ✅ Ready to customize for your needs\n\n## Technical Implementation Details\n\n### Detecting Generated Project Directory\n\n**Challenge**: After running a template command, we need to know which directory was created to apply migrations.\n\n**Solution**: Use `fspy` (a Rust file system monitoring crate) to monitor file operations during template execution, then derive the project directory from file paths.\n\n#### What fspy Achieves\n\n**Core Functionality**:\n\n- Monitor specific file operations (read/write of package.json) in real-time during template execution\n- Capture paths when package.json is written or read\n- Provide event stream of package.json operations\n- Efficient event-based watching without polling\n\n**How Vite+ Uses It**:\n\n1. Start fspy watcher to monitor package.json operations before executing template\n2. Execute template (template creates package.json in new project)\n3. Capture package.json write/create path (e.g., `packages/my-app/package.json`)\n4. Stop watcher when template completes\n5. **Derive project directory** from package.json path (e.g., from `packages/my-app/package.json` → extract `packages/my-app`)\n6. Use detected directory for subsequent migrations and workspace integration\n\n**Deduction Logic**:\n\n- Monitor for package.json file write/create operations\n- When package.json is written, capture its full path\n- Extract parent directory from the path (everything before `/package.json`)\n- This is the project directory\n\n**Example**:\n\n```\nCaptured file operation:\n- packages/my-app/package.json      ← write\n\nDerived project directory: packages/my-app\n```\n\n**Benefits of Using fspy**:\n\n- ✅ Real-time detection during template execution\n- ✅ Accurate - every template creates package.json\n- ✅ Efficient - only monitors package.json, not all files\n- ✅ Simple - package.json path directly reveals project directory\n- ✅ Works for all templates regardless of output format\n- ✅ Rust-based performance\n\n**Why This is Necessary**:\n\nAfter detecting the project directory, we can:\n\n1. **Apply migrations** in the correct location\n2. **Update package.json** in the right project\n3. **Register path** in workspace configuration (pnpm-workspace.yaml or package.json)\n4. **Display next steps** with correct `cd` command\n\n## Implementation Architecture\n\n### Directory Structure\n\n```\npackages/\n└── vite-generator/\n    ├── src/\n    │   ├── index.ts            # Main entry point\n    │   ├── cli.ts              # CLI command handler\n    │   ├── runner.ts           # Universal template runner\n    │   ├── discovery.ts        # Template/package discovery\n    │   ├── executor.ts         # Template execution with fspy monitoring\n    │   ├── detector.ts         # Detect generated code patterns\n    │   ├── migrator.ts         # Apply migrations with ast-grep\n    │   ├── workspace.ts        # Monorepo integration\n    │   ├── dependencies.ts     # Workspace dependency selection\n    │   └── directory.ts        # Project directory detection\n    ├── migrations/             # Migration rules (YAML)\n    │   ├── vite-build.yaml\n    │   ├── eslint-to-oxlint.yaml\n    │   ├── vitest-config.yaml\n    │   └── typescript-config.yaml\n    ├── package.json\n    └── tsconfig.json\n```\n\n**Key Dependencies**:\n\n- `fspy` - File system monitoring for detecting created directories\n- `@ast-grep/napi` - AST-based code transformation\n- `@clack/prompts` - Beautiful CLI prompts\n- `commander` - CLI argument parsing\n- `yaml` - Parse pnpm-workspace.yaml\n- `minimatch` or `micromatch` - Glob pattern matching for workspace patterns\n\n### Template Discovery\n\nTemplates can be located in multiple places:\n\n1. **Built-in scaffolds**: `@vite-plus/create-generator` - Scaffold for creating new generators\n2. **Workspace packages**: Generators within the monorepo (e.g., `@company/generator-api`, `tools/create-microservice`)\n3. **npm packages**: Any template from npm - bingo templates, create-\\* templates, etc.\n4. **Built-in Vite+**: Optional monorepo-specific generators (e.g., `vite:application`)\n\n**Resolution Order:**\n\n```\n1. Check if name is \"@vite-plus/create-generator\" → generator scaffold\n2. Check if name starts with \"vite:\" → built-in Vite+ generator\n3. Check workspace packages for matching name → workspace-local generator\n4. Check node_modules/{name}/package.json → installed template (any type)\n5. Check if it's an npm package name → offer to install from registry\n6. Error: template not found\n```\n\n**Template Type Detection**:\n\n- **Bingo template**: Has `bingo` dependency or `bingo-template` keyword\n- **Universal template**: Has `bin` entry in package.json\n- Both types are executed the same way and get the same post-processing\n\n**Example Workspace Structure:**\n\n```\nmonorepo/\n├── apps/                  # Applications (user can select)\n│   └── web-app/\n├── packages/              # Shared packages (user can select)\n│   └── shared-lib/\n├── services/              # Backend services (user can select)\n├── tools/                 # Development tools (user can select)\n│   └── generators/\n│       ├── ui-lib/             # @company/generator-ui-lib\n│       └── react-component/    # @company/generator-component\n├── pnpm-workspace.yaml    # For pnpm\n├── pnpm-lock.yaml         # Indicates pnpm usage\n└── package.json           # Root package.json (workspaces field for npm/yarn/bun)\n```\n\n**Workspace Configuration Examples:**\n\n**For pnpm (pnpm-workspace.yaml):**\n\n```yaml\npackages:\n  - 'apps/*'\n  - 'packages/*'\n  - 'services/*'\n  - 'tools/*'\n```\n\n**For npm/yarn/bun (package.json):**\n\n```json\n{\n  \"name\": \"my-monorepo\",\n  \"private\": true,\n  \"workspaces\": [\"apps/*\", \"packages/*\", \"services/*\", \"tools/*\"]\n}\n```\n\n**Detection Logic:**\n\n1. Check for lock files to determine package manager\n2. Read workspace config from appropriate file\n3. Extract parent directories: `apps`, `packages`, `services`, `tools`\n4. Prompt user to select one\n\n### Template Execution Pipeline\n\nVite+ acts as an intelligent wrapper that:\n\n1. **Pre-processing**:\n   - Detect template type (bingo vs universal)\n   - **If in monorepo**: Prompt for target directory (apps, packages, services, etc.)\n   - Discover workspace packages for dependency selection\n   - Capture pre-generation snapshot (for universal templates)\n\n2. **Execution**:\n   - Parse CLI arguments: options before `--` are for Vite+, options after `--` are for template\n   - Execute the template using Node.js: `node node_modules/{template}/bin/index.js [args-after---]`\n   - Pass through template arguments (everything after `--`)\n   - Template runs with full interactivity\n\n3. **Post-processing** (same for ALL templates):\n   - **Detect & Migrate**: Analyze generated code with ast-grep\n     - Detect standalone vite/vitest/oxlint/oxfmt\n     - Prompt user to upgrade to vite-plus unified toolchain\n     - Apply migration if confirmed\n   - **Monorepo Integration**:\n     - Prompt for workspace dependencies to add\n     - Update generated package.json with selected dependencies\n     - Update workspace config if needed (add to pnpm-workspace.yaml or package.json workspaces)\n     - Run `vp install` to link workspace dependencies\n\n**Implementation Note**:\n\n- Vite+ CLI parses options before `--` (e.g., `--no-migrate`, `--deps`)\n- Options after `--` are passed through to the template as-is\n- No Rust-JS bridge needed - we shell out to Node.js to run templates\n\n### Template Execution Flow\n\n```\n1. User runs: vp create [template-name] [vite-options] -- [template-options]\n   ↓\n2. Vite+ parses CLI arguments (split on -- separator)\n   ↓\n3. IF no template-name provided: Enter interactive mode\n   ├─ Show template selection menu (Vite+ Monorepo, Vite+ Generator, Vite, TanStack, Other)\n   ├─ Handle special templates with auto-argument injection\n   └─ Continue with selected template\n   ↓\n4. Vite+ checks if running in a monorepo workspace\n   ↓\n5. IF in monorepo: Prompt user to select target directory (apps, packages, etc.)\n   ↓\n6. Vite+ discovers and identifies template type (bingo vs universal)\n   ↓\n7. Vite+ captures pre-generation snapshot (file list)\n   ↓\n8. Vite+ loads workspace packages for dependency selection\n   ↓\n9. Vite+ starts fspy watcher to monitor package.json operations\n   ↓\n10. Vite+ executes template: node node_modules/{template}/bin/index.js [template-options]\n    (with cwd set to selected directory or passing directory as argument)\n    ↓\n11. Template runs (handles all prompts, validation, file generation)\n    ↓\n12. Template completes successfully\n    ↓\n13. Vite+ stops fspy watcher and derives project directory from package.json path\n    ↓\n14. Vite+ post-processes in detected project directory (same for ALL templates):\n\n   AUTO-MIGRATE TO VITE-PLUS:\n   ├─ Detect standalone vite/vitest/oxlint/oxfmt\n   ├─ Prompt to upgrade to vite-plus unified toolchain\n   └─ If yes, apply migration with ast-grep:\n       ├─ Dependencies: vite + vitest + oxlint + oxfmt → vite-plus\n       ├─ Merge vitest.config.ts → vite.config.ts\n       ├─ Merge .oxlintrc → vite.config.ts\n       ├─ Merge .oxfmtrc → vite.config.ts\n       └─ Remove standalone config files\n\n   MONOREPO INTEGRATION:\n   ├─ Prompt user to select workspace dependencies\n   ├─ Update package.json with workspace:* dependencies\n   ├─ Check if project path matches workspace patterns\n   ├─ If not matched: Update workspace config (pnpm-workspace.yaml or package.json)\n   ├─ Run vp install to link workspace dependencies\n   └─ Display next steps and tips\n```\n\nThis approach is **simple and robust**:\n\n- ✅ No need to embed a JavaScript runtime in Rust\n- ✅ No need to maintain compatibility with template APIs\n- ✅ Any template works out of the box (bingo or universal)\n- ✅ Template authors can publish to npm normally\n- ✅ Adds Vite+ optimization through intelligent migration\n- ✅ Seamless monorepo integration\n\n**Implementation Notes**:\n\n- **GitHub Templates**: Uses degit via npx for zero-config cloning\n- **Error Handling**: Provides context-specific troubleshooting tips\n- **Update Mode**: Foundation in place for transition mode generators\n- **Caching**: Relies on native npm/pnpm caching mechanisms\n\n## Usage Examples\n\n### Example 1: Universal Template (create-vite) with Auto-Execution\n\nWhen a template is not installed locally, Vite+ automatically uses the appropriate package manager runner:\n\n```bash\n$ vp create create-vite -- --template react-ts\n\n┌  🎨 Vite+ Code Generator\n│\n◇  Discovering template: create-vite\n│\n●  Template not installed locally, will run using pnpm dlx\n│\n◇  Executing template...\n│\n●  Running: pnpm dlx create-vite --template react-ts\n│\n# Template runs interactively via pnpm dlx...\n\n# Vite+ prompts for target directory in monorepo\n◆  Where should we create the new package?\n│  ○ apps/        (Applications)\n│  ● packages/    (Shared packages)\n│  ○ services/    (Backend services)\n│\n◇  Selected: packages/\n│\n# Template prompts\n✔ Project name: › my-react-app\n✔ Select a framework: › React\n✔ Select a variant: › TypeScript\n\nScaffolding project in ./packages/my-react-app...\n\nDone. Now run:\n  cd my-react-app\n  vp install\n  vp dev\n\n# Vite+ detects standalone vite tools\n◇  Template completed! Detecting vite-related tools...\n│\n◆  Detected standalone vite tools:\n│  ✓ vite ^5.0.0\n│  ✓ vitest ^1.0.0\n│\n◆  Upgrade to vite-plus unified toolchain?\n│\n│  This will:\n│  • Replace vite + vitest with single vite-plus dependency\n│  • Merge vitest.config.ts into vite.config.ts\n│  • Remove standalone vitest.config.ts\n│\n│  Benefits:\n│  • Unified dependency management\n│  • Single configuration file\n│  • Better integration with Vite+ task runner\n│\n│  ● Yes / ○ No\n│\n◇  Migrating to vite-plus...\n│  ✓ Updated package.json (vite + vitest → vite-plus)\n│  ✓ Merged vitest.config.ts → vite.config.ts\n│  ✓ Removed vitest.config.ts\n│\n◆  Add workspace packages as dependencies?\n│\n│  ◼ @company/ui-components - Shared React components\n│  ◼ @company/utils - Utility functions\n│  ◻ @company/api-client - API client library\n│\n◇  Selected: @company/ui-components, @company/utils\n│\n◇  Updating packages/my-react-app/package.json...\n◇  Added dependencies:\n│  - @company/ui-components@workspace:*\n│  - @company/utils@workspace:*\n│\n◇  Checking workspace configuration...\n◇  Project matches pattern 'packages/*' ✓\n◇  Running vp install...\n│\n└  Done!\n\n🎉 Successfully created my-react-app with vite-plus\n\nNext steps:\n  cd packages/my-react-app\n  vp dev\n```\n\n### Example 2: Complete Monorepo Integration Flow\n\nThis example shows the full monorepo integration with workspace dependency selection:\n\n```bash\n$ cd my-monorepo\n$ vp create create-vite -- --template react-ts\n\n┌  🎨 Vite+ Code Generator\n│\n◆  Where should we create the new package?\n│  ○ apps/\n│  ● packages/\n│  ○ tools/\n│\n◇  Selected: packages/\n│\n◇  Discovering template: create-vite\n│\n●  Template not installed locally, will run using pnpm dlx\n│\n◇  Executing template...\n│\n●  Running: pnpm dlx create-vite --template react-ts\n│\n# create-vite runs interactively...\n✔ Project name: › ui-components\n✔ Select a framework: › React\n✔ Select a variant: › TypeScript\n\nScaffolding project in ./ui-components...\n\nDone. Now run:\n  cd ui-components\n  npm install\n  npm run dev\n\n◆  Template executed successfully\n│\n◆  Detected project directory: packages/ui-components\n│\n◇  Auto-migration to Vite+...\n│\n●  Detected standalone vite tools: vite, vitest\n│\n◆  Upgrade to vite-plus unified toolchain?\n│  ● Yes / ○ No\n│\n●  This will:\n│  • Replace vite + vitest with single vite dependency\n│  • Update script commands to use vite CLI\n│  • Use catalog: version\n│\n◆  Migrated to vite-plus ✓\n│  • Removed: vite, vitest\n│  • Added: vite (catalog:)\n│\n◇  Monorepo integration...\n│\n◆  Add workspace packages as dependencies?\n│  ◼ @company/utils - Utility functions\n│  ◼ @company/theme - Design tokens and theme\n│  ◻ @company/icons - Icon library\n│  ◻ @company/api-client - API client\n│\n◇  Selected: @company/utils, @company/theme\n│\n◆  Added 2 workspace dependencies\n│  • @company/utils@workspace:*\n│  • @company/theme@workspace:*\n│\n◆  Project matches workspace pattern ✓\n│\n◒  Running vp install...\n│\n◆  Dependencies linked\n│\n└  ✨ Generation completed!\n\nNext steps:\n  cd packages/ui-components\n  vp dev\n```\n\n### Example 3: Creating a Generator Scaffold\n\nUse the built-in `vite:generator` to quickly scaffold a new generator:\n\n```bash\n$ vp create vite:generator\n\n┌  🎨 Vite+ Code Generator\n│\n◇  Discovering template: vite:generator\n│\n◆  Found builtin template: vite:generator\n│\n◇  Creating generator scaffold...\n│\n◇  Generator name:\n│  ui-lib\n│\n◇  Package name:\n│  @company/generator-ui-lib\n│\n◇  Description:\n│  Generate new UI component libraries\n│\n◇  Where to create?\n│  tools/generators/ui-lib\n│\n◆  Generator scaffold created\n│  • package.json\n│  • bin/index.js\n│  • src/template.ts\n│  • README.md\n│  • tsconfig.json\n│\n◆  Detected project directory: tools/generators/ui-lib\n│\n◇  Monorepo integration...\n│\n◆  Project doesn't match existing workspace patterns\n│\n◆  Update workspace configuration to include this project?\n│  ● Yes\n│\n◆  Updated workspace configuration\n│\n◒  Running vp install...\n│\n◆  Dependencies linked\n│\n└  ✨ Generation completed!\n\nSummary:\n  • Template: vite:generator (builtin)\n  • Created: tools/generators/ui-lib\n  • Actions: Updated workspace config\n\nNext steps:\n  cd tools/generators/ui-lib\n  # Edit src/template.ts to customize your generator\n  # Then test it with: vp create @company/generator-ui-lib\n```\n\nThe generated scaffold includes:\n\n- **package.json**: Pre-configured with bingo, zod, bin entry, keywords\n- **bin/index.js**: Executable entry point that runs the template\n- **src/template.ts**: Example bingo template with TODO comments for customization\n- **README.md**: Usage instructions and development guide\n- **tsconfig.json**: TypeScript configuration extending monorepo root\n\n### Example 4: Built-in vite:application Generator\n\nUse the built-in `vite:application` generator for a Vite+ optimized project:\n\n```bash\n$ vp create vite:application\n\n┌  🎨 Vite+ Code Generator\n│\n◇  Discovering template: vite:application\n│\n◆  Found builtin template: vite:application\n│\n◇  Creating vite application...\n│\n◆  Select a framework:\n│  ● React (React with TypeScript)\n│  ○ Vue (Vue with TypeScript)\n│  ○ Svelte (Svelte with TypeScript)\n│  ○ Solid (Solid with TypeScript)\n│  ○ Vanilla (Vanilla TypeScript)\n│\n◇  Project name:\n│  my-app\n│\n◇  Generating react-ts project...\n│\n# create-vite runs...\n│\n◆  Project generated with Vite+ configuration\n│  • Added vite-task.json with build/test/lint/dev tasks\n│\n◆  Detected project directory: my-app\n│\n◇  Auto-migration to Vite+...\n│\n●  Detected standalone vite tools: vite\n│\n◆  Migrated to vite-plus ✓\n│\n└  ✨ Generation completed!\n\nSummary:\n  • Template: vite:application (builtin)\n  • Created: my-app\n  • Actions: Migrated to vite-plus\n\nNext steps:\n  cd my-app\n  vp dev\n```\n\nThe generated project includes:\n\n- Standard create-vite project structure\n- **vite-task.json**: Pre-configured tasks (build, test, lint, dev)\n- **Migrated**: Already using vite-plus instead of standalone vite\n- **Ready**: Immediately usable with Vite+ task runner\n\n### Example 5: Bingo Template (create-typescript-app)\n\n```bash\n# Use create-typescript-app (a popular bingo template)\nvp create create-typescript-app\n\n┌  vp create create-typescript-app\n│\n# Vite+ prompts for target directory first\n◆  Where should we create the new package?\n│  ○ apps/\n│  ● packages/\n│  ○ tools/\n│\n◇  Selected: packages/\n│\n# Bingo's interactive prompts\n◇  Repository name: my-lib\n◇  Repository owner: mycompany\n◇  Which preset? › common\n│\n└  Template completed successfully!\n\n# Vite+ ALSO detects standalone vite tools (even for bingo templates)\n◇  Template completed! Detecting vite-related tools...\n│\n◆  Detected standalone vite tools:\n│  ✓ vite ^5.0.0\n│  ✓ vitest ^1.0.0\n│\n◆  Upgrade to vite-plus unified toolchain?\n│\n│  This will:\n│  • Replace vite + vitest with single vite-plus dependency\n│  • Merge vitest.config.ts into vite.config.ts\n│\n│  ● Yes / ○ No\n│\n◇  Migrating to vite-plus...\n│  ✓ Updated package.json dependencies\n│  ✓ Merged vitest.config.ts → vite.config.ts\n│  ✓ Removed vitest.config.ts\n│\n◆  Add workspace packages as dependencies?\n│\n│  ◼ @mycompany/utils - Utility functions\n│  ◼ @mycompany/logger - Logging library\n│  ◻ @mycompany/database - Database client\n│\n◇  Selected: @mycompany/utils, @mycompany/logger\n│\n◇  Updating packages/my-lib/package.json...\n◇  Checking workspace configuration...\n◇  Project matches pattern 'packages/*' ✓\n◇  Running vp install...\n│\n└  Done!\n\n🎉 Successfully created my-lib with Vite+ optimizations\n\nNext steps:\n  cd packages/my-lib\n  vp dev\n```\n\n**Notice**: Even though create-typescript-app is a bingo template, it still gets the same auto-migration treatment to optimize for Vite+!\n\n### Example 3: Creating a Workspace-Local Bingo Generator\n\n#### Quick Start with @vite-plus/create-generator\n\nUse the official scaffold to quickly create a new generator:\n\n```bash\n# Create a new generator in your monorepo\nvp create @vite-plus/create-generator\n\n┌  @vite-plus/create-generator\n│\n◇  Generator name: ui-lib\n◇  Generator package name: @company/generator-ui-lib\n◇  Description: Generate new UI component libraries for our monorepo\n◇  Where to create? › tools/generators/ui-lib\n│\n◇  Creating generator scaffold...\n│  ✓ Created package.json\n│  ✓ Created bin/index.js\n│  ✓ Created src/template.ts\n│  ✓ Created src/template.test.ts\n│  ✓ Created README.md\n│\n◇  Installing dependencies...\n│\n└  Done!\n\n✅ Generator created at tools/generators/ui-lib\n\nNext steps:\n  1. cd tools/generators/ui-lib\n  2. Edit src/template.ts to define your generator logic\n  3. Test with: vp create @company/generator-ui-lib\n```\n\n#### Generated Structure\n\nIn a real monorepo, write custom bingo generators as proper packages alongside your apps and libraries:\n\n```\nmonorepo/\n├── apps/\n│   ├── api-gateway/\n│   └── web-app/\n├── packages/                    # Generated packages go here\n├── tools/\n│   └── generators/\n│       └── ui-lib/              # The generator package\n│           ├── package.json\n│           ├── bin/\n│           │   └── index.js\n│           ├── src/\n│           │   └── template.ts\n│           └── templates/\n│               ├── package.json.hbs\n│               └── src/\n│                   └── index.ts.hbs\n└── pnpm-workspace.yaml\n```\n\n**Generator Package Configuration** (auto-generated by `@vite-plus/create-generator`):\n\n```json\n// tools/generators/ui-lib/package.json\n{\n  \"name\": \"@company/generator-ui-lib\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"private\": true,\n  \"description\": \"Generate new UI component libraries for our monorepo\",\n  \"bin\": {\n    \"create-ui-lib\": \"./bin/index.js\"\n  },\n  \"keywords\": [\"bingo-template\", \"vite-plus-generator\"],\n  \"scripts\": {\n    \"test\": \"vitest\"\n  },\n  \"dependencies\": {\n    \"bingo\": \"^0.5.0\",\n    \"zod\": \"^3.22.0\"\n  },\n  \"devDependencies\": {\n    \"bingo-testers\": \"^0.5.0\",\n    \"vitest\": \"^1.0.0\",\n    \"@types/node\": \"^20.0.0\",\n    \"typescript\": \"^5.3.0\"\n  }\n}\n```\n\n**Template Implementation** (scaffold provided by `@vite-plus/create-generator`):\n\nThe scaffold includes a complete, working example that you can customize:\n\n```typescript\n// tools/generators/ui-lib/src/template.ts\nimport { createTemplate } from 'bingo';\nimport { z } from 'zod';\n\n// This file is scaffolded by @vite-plus/create-generator\n// Edit the options and produce() function to customize your generator\n\nexport default createTemplate({\n  about: {\n    name: 'UI Library Generator',\n    description: 'Create a new React component library with TypeScript',\n  },\n\n  options: {\n    name: z\n      .string()\n      .regex(/^[a-z][a-z0-9-]*$/, 'Must be lowercase with hyphens')\n      .describe('Library name (e.g., design-system, ui-components)'),\n\n    framework: z.enum(['react', 'vue', 'svelte']).default('react').describe('UI framework'),\n\n    storybook: z.boolean().default(true).describe('Include Storybook for component documentation'),\n\n    cssInJs: z.boolean().default(false).describe('Include CSS-in-JS library (styled-components)'),\n  },\n\n  async produce({ options }) {\n    const libPath = `packages/${options.name}`;\n    const packageName = `@company/${options.name}`;\n\n    return {\n      files: {\n        [`${libPath}/package.json`]: JSON.stringify(\n          {\n            name: packageName,\n            version: '0.1.0',\n            type: 'module',\n            private: true,\n            main: './dist/index.js',\n            module: './dist/index.mjs',\n            types: './dist/index.d.ts',\n            exports: {\n              '.': {\n                import: './dist/index.mjs',\n                require: './dist/index.js',\n                types: './dist/index.d.ts',\n              },\n            },\n            scripts: {\n              dev: 'vite',\n              build: 'vp build && tsc --emitDeclarationOnly',\n              test: 'vitest',\n              lint: 'oxlint',\n              ...(options.storybook && { storybook: 'storybook dev -p 6006' }),\n            },\n            peerDependencies: {\n              [options.framework]: '^18.0.0',\n            },\n            dependencies: {\n              ...(options.cssInJs && { 'styled-components': '^6.0.0' }),\n            },\n            devDependencies: {\n              '@types/react': '^18.0.0',\n              '@vitejs/plugin-react': '^4.2.0',\n              typescript: '^5.3.0',\n              vitest: '^1.0.0',\n              vite: '^5.0.0',\n              ...(options.storybook && {\n                '@storybook/react': '^7.6.0',\n                '@storybook/react-vite': '^7.6.0',\n              }),\n            },\n          },\n          null,\n          2,\n        ),\n\n        [`${libPath}/tsconfig.json`]: JSON.stringify(\n          {\n            extends: '../../tsconfig.base.json',\n            compilerOptions: {\n              outDir: './dist',\n              rootDir: './src',\n              declaration: true,\n              declarationMap: true,\n            },\n            include: ['src/**/*'],\n          },\n          null,\n          2,\n        ),\n\n        [`${libPath}/vite.config.ts`]: `\nimport { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n  plugins: [react()],\n  build: {\n    lib: {\n      entry: './src/index.ts',\n      formats: ['es', 'cjs'],\n      fileName: (format) => \\`index.\\${format === 'es' ? 'mjs' : 'js'}\\`,\n    },\n    rollupOptions: {\n      external: ['react', 'react-dom'],\n    },\n  },\n});\n        `.trim(),\n\n        [`${servicePath}/src/index.ts`]: `\nimport express from 'express';\n${options.authentication ? \"import { authMiddleware } from './middleware/auth.js';\" : ''}\n${options.database !== 'none' ? `import { initDatabase } from './database.js';` : ''}\n\nconst app = express();\nconst PORT = ${options.port};\n\n// Middleware\napp.use(express.json());\n${options.authentication ? 'app.use(authMiddleware);' : ''}\n\n// Routes\napp.get('/health', (req, res) => {\n  res.json({\n    status: 'ok',\n    service: '${options.name}',\n    timestamp: new Date().toISOString(),\n  });\n});\n\napp.get('/api/${options.name}', (req, res) => {\n  res.json({ message: 'Hello from ${options.name}!' });\n});\n\n// Start server\nasync function start() {\n  ${options.database !== 'none' ? 'await initDatabase();' : ''}\n\n  app.listen(PORT, () => {\n    console.log(\\`🚀 ${options.name} running on http://localhost:\\${PORT}\\`);\n  });\n}\n\nstart().catch(console.error);\n        `.trim(),\n\n        ...(options.database !== 'none' && {\n          [`${servicePath}/src/database.ts`]: `\nimport { ${getDatabaseClient(options.database)} } from '${getDatabasePackage(options.database)}';\n\nexport async function initDatabase() {\n  // TODO: Initialize ${options.database} connection\n  console.log('📦 Database connected');\n}\n          `.trim(),\n        }),\n\n        ...(options.authentication && {\n          [`${servicePath}/src/middleware/auth.ts`]: `\nimport { Request, Response, NextFunction } from 'express';\n\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n  // TODO: Implement JWT authentication\n  next();\n}\n          `.trim(),\n        }),\n\n        [`${servicePath}/.env.example`]: [\n          `PORT=${options.port}`,\n          `NODE_ENV=development`,\n          options.database !== 'none' &&\n            `DATABASE_URL=${getDatabaseUrl(options.database, options.name)}`,\n        ]\n          .filter(Boolean)\n          .join('\\n'),\n\n        [`${servicePath}/README.md`]: `\n# ${packageName}\n\nAPI microservice for ${options.name}.\n\n## Development\n\n\\`\\`\\`bash\n# Install dependencies\nvp install\n\n# Start dev server\nvp dev\n\n# Run tests\nvp test\n\n# Build\nvp build\n\\`\\`\\`\n\n## Configuration\n\n- Port: ${options.port}\n- Database: ${options.database}\n- Authentication: ${options.authentication ? 'Enabled' : 'Disabled'}\n        `.trim(),\n      },\n\n      scripts: [\n        {\n          phase: 0,\n          commands: [`cd ${libPath}`, 'vp install'],\n        },\n      ],\n\n      suggestions: [\n        `✅ Created ${options.name} component library in ${libPath}`,\n        ``,\n        `Next steps:`,\n        `  1. cd ${libPath}`,\n        `  2. Add your components to src/components/`,\n        `  3. Export them in src/index.ts`,\n        `  4. vp build`,\n        options.storybook && `  5. npm run storybook (view component docs)`,\n        ``,\n        `The library is ready to be used in other packages!`,\n      ].filter(Boolean),\n    };\n  },\n});\n```\n\n**CLI Entrypoint** (auto-generated by `@vite-plus/create-generator`):\n\n```javascript\n#!/usr/bin/env node\n// tools/generators/ui-lib/bin/index.js\nimport { runTemplate } from 'bingo';\nimport template from '../src/template.js';\n\nrunTemplate(template);\n```\n\n**README** (auto-generated by `@vite-plus/create-generator`):\n\n```markdown\n# @company/generator-ui-lib\n\nGenerate new UI component libraries for our monorepo.\n\n## Usage\n\nFrom monorepo root:\n\n\\`\\`\\`bash\nvp create @company/generator-ui-lib\n\\`\\`\\`\n\nWith options:\n\n\\`\\`\\`bash\nvp create @company/generator-ui-lib --name=design-system --framework=react\n\\`\\`\\`\n\n## Development\n\n\\`\\`\\`bash\n\n# Run tests\n\nvp test\n\n# Test the generator\n\nvp create @company/generator-ui-lib\n\\`\\`\\`\n\n## Customization\n\nEdit `src/template.ts` to customize:\n\n- Options schema (using Zod)\n- File generation logic\n- Scripts and suggestions\n```\n\n**Usage in the Monorepo:**\n\n```bash\n# Run from monorepo root\nvp create @company/generator-ui-lib\n\n# Bingo generator prompts\n┌  @company/generator-ui-lib\n│\n◇  Library name: design-system\n◇  Framework: React\n◇  Include Storybook? Yes\n◇  Include CSS-in-JS? No\n│\n└  Template completed!\n\n# Vite+ ALSO detects standalone vite tools (even for bingo templates!)\n◇  Template completed! Detecting vite-related tools...\n│\n◆  Detected standalone vite tools:\n│  ✓ vite ^5.0.0\n│  ✓ vitest ^1.0.0\n│\n◆  Upgrade to vite-plus unified toolchain?\n│\n│  This will:\n│  • Replace vite + vitest with vite-plus\n│  • Merge configs into vite.config.ts\n│\n│  ● Yes / ○ No\n│\n◇  Migrating to vite-plus...\n│  ✓ Updated package.json dependencies\n│  ✓ Merged vitest.config.ts → vite.config.ts\n│  ✓ Removed standalone config files\n│\n◆  Add workspace packages as dependencies?\n│\n│  ◼ @company/theme - Design tokens and theme\n│  ◼ @company/utils - Utility functions\n│  ◼ @company/icons - Icon library\n│  ◻ @company/hooks - React hooks\n│\n◇  Selected: @company/theme, @company/utils, @company/icons\n│\n◇  Updating packages/design-system/package.json...\n◇  Checking workspace configuration...\n◇  Project matches pattern 'packages/*' ✓\n◇  Running vp install...\n│\n└  Done!\n\n✅ Created design-system component library with Vite+ optimizations\n\nNext steps:\n  1. cd packages/design-system\n  2. Add components to src/components/\n  3. Export in src/index.ts\n  4. vp build\n  5. npm run storybook (view docs)\n\n# CLI options\nvp create @company/generator-ui-lib --name=icons --no-migrate  # Skip migrations\nvp create @company/generator-ui-lib --name=hooks --deps=@company/utils  # Pre-select deps\n```\n\n**Key Point**: Even your own bingo generators benefit from auto-migration! You can generate code using standalone vite/vitest/oxlint, and Vite+ will automatically consolidate them into vite-plus.\n\n**Tip**: Use `vp create @vite-plus/create-generator` to quickly scaffold a new generator in your monorepo!\n\n**Testing the Generator:**\n\n```typescript\n// tools/generators/ui-lib/src/template.test.ts\nimport { testTemplate } from 'bingo/testers';\nimport { describe, expect, it } from 'vitest';\nimport template from './template.js';\n\ndescribe('UI Library Generator', () => {\n  it('generates library with storybook', async () => {\n    const result = await testTemplate(template, {\n      options: {\n        name: 'design-system',\n        framework: 'react',\n        storybook: true,\n        cssInJs: false,\n      },\n    });\n\n    expect(result.files['packages/design-system/package.json']).toContain('@company/design-system');\n    expect(result.files['packages/design-system/package.json']).toContain('@storybook/react');\n    expect(result.files['packages/design-system/src/components/Button.tsx']).toBeDefined();\n    expect(result.files['packages/design-system/.storybook/main.ts']).toBeDefined();\n  });\n\n  it('generates library without storybook', async () => {\n    const result = await testTemplate(template, {\n      options: {\n        name: 'icons',\n        framework: 'react',\n        storybook: false,\n        cssInJs: false,\n      },\n    });\n\n    expect(result.files['packages/icons/.storybook/main.ts']).toBeUndefined();\n    expect(result.files['packages/icons/src/components/Button.stories.tsx']).toBeUndefined();\n  });\n});\n```\n\n### Built-in Vite+ Generator (Optional)\n\nFor monorepo-specific needs, Vite+ can provide thin wrappers:\n\n```bash\n# Built-in generator that configures vite-task.json automatically\nvp create vite:library --name=shared-utils\n\n# This could wrap an existing bingo template and add:\n# - vite-task.json with build/test/lint tasks\n# - Proper workspace structure\n# - TypeScript configuration for monorepo\n```\n\n## Technical Considerations\n\n### 1. Process Execution & Directory Detection\n\n- **Subprocess Management**: Use Node.js `child_process.spawn()` to execute templates\n- **Stdio Handling**: Use `inherit` mode to pass through stdin/stdout/stderr for interactive prompts\n- **Directory Detection**: Use `fspy` to monitor package.json operations during template execution\n  - Watch specifically for package.json write/create operations\n  - Capture package.json file path when written\n  - Derive project root from package.json path (extract parent directory)\n  - Handle multiple package.json writes (choose the first top-level one)\n  - Handle in-place generation (package.json created in cwd)\n- **Exit Codes**: Properly handle template exit codes and surface errors\n- **Working Directory**: Use `cwd` option to ensure template runs in the correct directory\n\n### 2. Package Manager Detection & Workspace Config\n\n- **Auto-detect**: Determine package manager from lock files\n  - `pnpm-lock.yaml` → pnpm\n  - `package-lock.json` → npm\n  - `yarn.lock` → yarn\n  - `bun.lockb` → bun\n- **Read Workspace Config**: Based on detected package manager\n  - **pnpm**: Read `pnpm-workspace.yaml`, parse `packages` array\n  - **npm/yarn/bun**: Read root `package.json`, parse `workspaces` array\n- **Respect Workspace**: Use the same package manager as the monorepo\n- **Installation**: When prompting to install, use the detected package manager\n\n### 3. Template Detection\n\n- **package.json Parsing**: Read package.json to check for bingo dependency\n- **Bin Entry**: Look for bin field to find the executable\n- **Keywords**: Check for \"bingo-template\" keyword as fallback\n- **Validation**: Warn if package doesn't look like a valid bingo template\n\n### 4. Monorepo Integration\n\n- **Workspace Detection**: Check if running in a monorepo workspace\n- **Directory Selection**: Prompt user to select target directory (apps, packages, services, etc.)\n  - Read workspace config (pnpm-workspace.yaml or package.json workspaces)\n  - Extract parent directories from workspace patterns\n  - Present as interactive selection\n  - Can be overridden with `--directory` flag\n- **Workspace Package Discovery**: Load all packages from workspace with their metadata\n  - Use detected package manager's workspace patterns\n  - Resolve glob patterns to find all packages\n- **Dependency Selection UI**: Multi-select prompt for choosing workspace dependencies\n- **Smart Filtering**: Filter packages by type (exclude generators, include libraries)\n- **Version Protocol**: Use `workspace:*` for workspace dependencies\n- **Package.json Updates**: Parse and update generated package.json with selected deps\n- **Workspace Registration**: Check if detected project matches existing workspace patterns\n  - If matches (e.g., `packages/my-app` matches `packages/*`): ✅ No update needed\n  - If not matched: Update workspace config file\n    - **pnpm**: Add pattern to pnpm-workspace.yaml `packages` array\n    - **npm/yarn/bun**: Add pattern to package.json `workspaces` array\n- **Dependency Installation**: Run `vp install` to link workspace dependencies\n- **Path Normalization**: Ensure paths are relative to workspace root\n- **Idempotency**: Don't duplicate patterns if already exist\n\n### 5. Error Handling\n\n- **Clear Messages**: Distinguish between Vite+ errors and template errors\n- **Installation Failures**: Handle vp install failures gracefully\n- **Partial Completion**: If template creates files but errors, inform user\n- **Troubleshooting**: Provide hints for common issues (Node.js not found, etc.)\n\n### 6. Testing\n\nTesting can leverage bingo's own test utilities:\n\n```typescript\n// Template authors test using bingo's testing tools\nimport { testTemplate } from 'bingo/testers';\nimport { expect, test } from 'vitest';\nimport template from './template';\n\ntest('generates React app', async () => {\n  const result = await testTemplate(template, {\n    options: { name: 'my-app', framework: 'react' },\n  });\n\n  expect(result.files['package.json']).toContain('react');\n});\n```\n\nVite+ template runner logic is also tested using TypeScript/Vitest:\n\n```typescript\n// In vite_generator package\nimport { describe, expect, it } from 'vitest';\nimport { detectBingoTemplate, loadWorkspacePackages } from './discovery';\n\ndescribe('Template Detection', () => {\n  it('detects bingo template from package.json', async () => {\n    const pkg = {\n      name: 'create-typescript-app',\n      dependencies: { bingo: '^0.5.0' },\n      bin: { 'create-typescript-app': './bin/index.js' },\n    };\n\n    expect(detectBingoTemplate(pkg)).toBe(true);\n  });\n\n  it('detects bingo template from keywords', async () => {\n    const pkg = {\n      name: 'my-template',\n      keywords: ['bingo-template'],\n      bin: { 'my-template': './index.js' },\n    };\n\n    expect(detectBingoTemplate(pkg)).toBe(true);\n  });\n});\n\ndescribe('Workspace Package Discovery', () => {\n  it('loads all workspace packages', async () => {\n    const packages = await loadWorkspacePackages('/path/to/monorepo');\n\n    expect(packages).toContainEqual({\n      name: '@company/logger',\n      path: 'packages/logger',\n      description: 'Logging library',\n    });\n  });\n\n  it('filters out generator packages', async () => {\n    const packages = await loadWorkspacePackages('/path/to/monorepo', {\n      excludeGenerators: true,\n    });\n\n    expect(packages.every((pkg) => !pkg.name.includes('generator'))).toBe(true);\n  });\n});\n```\n\n## Comparison: Bingo vs Universal Templates\n\n| Aspect                 | Bingo Templates              | Universal Templates              |\n| ---------------------- | ---------------------------- | -------------------------------- |\n| **Writing Experience** | ✅ Type-safe (Zod), testable | ⚠️ No standard, varies           |\n| **Examples**           | @company/generator-ui-lib    | create-vite, create-next-app     |\n| **Customization**      | ✅ Full control              | ⚠️ Limited to template options   |\n| **Auto-Migration**     | ✅ Yes (same as universal)   | ✅ Yes (same as bingo)           |\n| **Ecosystem**          | ~5-10 bingo templates        | Thousands of create-\\* templates |\n| **Learning Curve**     | Medium (learn bingo)         | Zero (use familiar templates)    |\n| **Maintenance**        | Maintained by you            | Maintained by template authors   |\n| **Best For**           | Custom company generators    | Quick starts, standard setups    |\n\n**Key Point**: Both bingo and universal templates get the **same auto-migration to vite-plus**. The difference is only in the authoring experience:\n\n- **Choose bingo** when you want to write custom generators with type safety and testing\n- **Choose universal** when you want to use existing templates from the ecosystem\n- **Both get auto-migrated** to vite-plus unified toolchain automatically!\n\n## Open Questions\n\n1. **Auto-installation UX**: Should we auto-install templates or always prompt first?\n2. **Template Caching**: Should we cache installed templates or always fetch latest?\n3. **Built-in Generators**: Which built-in generators (if any) should we provide?\n4. **Directory Selection**:\n   - Should we infer descriptions from directory names (e.g., \"apps\" → \"Applications\")?\n   - Should we support adding custom directories via `--directory=custom/path`?\n   - Should we remember last selected directory for next generation?\n5. **Dependency Selection**:\n   - Should we filter packages by type (e.g., exclude test utilities)?\n   - Should we have smart defaults based on generator type?\n   - Should we support dependency groups (e.g., \"common backend libs\")?\n6. **Version Protocol**: Always use `workspace:*` or allow specific version ranges?\n7. **Migration Safety**:\n   - Should we create backups before applying migrations?\n   - How to handle migration conflicts?\n   - Support rollback of migrations?\n8. **ast-grep Integration**:\n   - Should migration rules be YAML or TypeScript?\n   - Should we allow custom user-defined migrations?\n9. **Extensibility**:\n   - Plugin system for third-party migrations?\n   - How to share migrations across teams?\n\n## Success Criteria\n\nA successful implementation should:\n\n### Template Support\n\n1. ✅ Run ANY bingo template from npm without modification (via npx/pnpm dlx)\n2. ✅ Run ANY create-\\* or other universal templates (via npx/pnpm dlx)\n3. ✅ Support workspace-local bingo generators (scan workspace patterns)\n4. ✅ Auto-detect template type (bingo vs universal)\n5. ✅ Parse CLI arguments correctly (Vite+ options before `--`, template options after `--`)\n6. ✅ Pass through template options correctly (everything after `--`)\n7. ✅ Handle interactive prompts properly (stdio inheritance)\n8. ✅ Detect generated project directory (directory scanning for package.json)\n9. ✅ Built-in @vite-plus/create-generator for scaffolding new generators\n10. ✅ Built-in vite:application and vite:library placeholders\n\n### Auto-Migration to vite-plus\n\n9. ✅ Automatically detect standalone vite/vitest/oxlint/oxfmt (in detected project directory)\n10. ✅ Prompt to upgrade to vite-plus unified toolchain\n11. ✅ Consolidate dependencies (vite + vitest + oxlint + oxfmt → unified vite)\n12. ✅ Update script commands (vitest → vp test, oxlint → vp lint, etc.)\n13. ✅ Provide clear before/after explanations\n14. ✅ Be safe and reversible\n15. ⏳ Merge configurations (vitest.config.ts, .oxlintrc, .oxfmtrc → vite.config.ts) - Future enhancement with ast-grep\n\n### Monorepo Integration\n\n16. ✅ Detect monorepo workspace and prompt for target directory\n17. ✅ Support `--directory` flag to skip directory selection\n18. ✅ Integrate with workspace (auto-update pnpm-workspace.yaml or package.json workspaces if needed)\n19. ✅ Prompt for workspace dependencies with multi-select UI\n20. ✅ Use `workspace:*` protocol for internal dependencies\n21. ✅ Run `vp install` to link dependencies\n22. ✅ Work with npm, pnpm, yarn, and bun package managers\n23. ✅ Smart package filtering (exclude generators from dependency selection)\n24. ✅ Support `--deps` flag for pre-selecting dependencies\n25. ✅ Workspace pattern matching and auto-update\n\n### Developer Experience\n\n26. ✅ No installation required (use npx/pnpm dlx/yarn dlx/bunx)\n27. ✅ Clear error messages distinguishing Vite+ vs template errors\n28. ✅ Beautiful interactive prompts with @clack/prompts\n29. ✅ Show progress and feedback during generation/migration\n30. ✅ Display helpful next steps after completion (with correct directory path)\n31. ✅ Interactive mode with curated template selection\n32. ✅ Automatic argument injection for known templates\n\n## Benefits of This Approach\n\n### For Vite+ Users\n\n- 🎯 **Maximum Choice**: Use bingo templates OR any create-\\* template\n- 🚀 **Zero Learning Curve**: Use familiar templates (create-vite, create-next-app, etc.)\n- 🔧 **Automatic Optimization**: Intelligent migration to Vite+ toolchain\n- 🌍 **Entire Ecosystem**: Access to thousands of existing templates\n- 💼 **Company Generators**: Build reusable bingo templates for your team\n- 🔄 **Future-proof**: Works with templates created in the future\n\n### For Template Authors\n\n**Bingo Template Authors:**\n\n- ✍️ **Full Control**: Type-safe, testable, company-specific generators\n- 📦 **Monorepo-first**: Perfect for workspace-local generators\n- 🧪 **Built-in Testing**: Use bingo's testing utilities\n- 🚀 **Quick Start**: Use `@vite-plus/create-generator` to scaffold new generators\n\n**Universal Template Authors:**\n\n- ⚡ **Zero Effort**: Templates work as-is\n- 👥 **Wider Audience**: Automatically compatible with Vite+\n- 🔄 **No Maintenance**: Vite+ handles the optimization\n\n### For Vite+ Maintainers\n\n- 🎯 **Best of Both Worlds**: Support both approaches\n- 🐛 **Simpler Architecture**: Templates run as-is, no complex API\n- 📚 **Leverage Docs**: Point to existing template documentation\n- ⚡ **Immediate Value**: Add value through intelligent post-processing\n- 🔧 **Extensible**: Easy to add new migrations as Vite+ evolves\n\n## Related RFCs\n\n- [migration-command.md](./migration-command.md) - `vp migrate` command for migrating existing projects\n  - Shares the same migration engine and rules\n  - `vp create` runs migrations after template generation\n  - `vp migrate` runs migrations on existing projects\n\n## References\n\n### Template Frameworks\n\n- [Bingo Framework](https://www.create.bingo/) - Type-safe repository templates\n- [Bingo FAQs](https://www.create.bingo/faqs/)\n- [create-typescript-app](https://github.com/JoshuaKGoldberg/create-typescript-app) - Production bingo template\n- [create-vite](https://github.com/vitejs/vite/tree/main/packages/create-vite) - Official Vite templates\n\n### Code Transformation\n\n- [ast-grep](https://ast-grep.github.io/) - Structural search and replace tool\n- [Turborepo Codemods](https://turborepo.com/docs/reference/turbo-codemod) - Similar migration approach\n- [jscodeshift](https://github.com/facebook/jscodeshift) - Alternative AST transformation tool\n\n### Inspiration\n\n- [Nx Generators](https://nx.dev/docs/features/generate-code) - Nx's generator system\n- [Turborepo Code Generation](https://turborepo.com/docs/guides/generating-code) - Turbo's PLOP-based approach\n- [PLOP Documentation](https://plopjs.com/documentation/) - Micro-generator framework\n\n### Tools\n\n- [Zod](https://zod.dev/) - TypeScript schema validation\n- [@clack/prompts](https://www.npmjs.com/package/@clack/prompts) - Beautiful CLI prompts\n"
  },
  {
    "path": "rfcs/config-and-staged-commands.md",
    "content": "# RFC: Built-in Pre-commit Hook via `vp config` + `vp staged`\n\n## Summary\n\nAdd `vp config` and `vp staged` as built-in commands. `vp config` is a `prepare`-lifecycle command that installs git hook shims (husky-compatible reimplementation, not a bundled dependency). `vp staged` bundles lint-staged and reads config from the `staged` key in `vite.config.ts`. Projects get a zero-config pre-commit hook that runs `vp check --fix` on staged files — no extra devDependencies needed.\n\n## Motivation\n\nCurrently, setting up pre-commit hooks in a Vite+ project requires:\n\n1. Installing husky and lint-staged as devDependencies\n2. Configuring husky hooks\n3. Configuring lint-staged\n\nPain points:\n\n- **Extra devDependencies** that every project needs\n- **Manual setup steps** after `vp create` or `vp migrate`\n- **No standardized pre-commit workflow** across Vite+ projects\n- husky and lint-staged are universal enough to be built in\n\nBy building these capabilities into vite-plus, projects get pre-commit hooks with zero extra devDependencies. Both `vp create` and `vp migrate` set this up automatically.\n\n## User Workflows\n\nThere are three distinct entry points, each with a different responsibility:\n\n### New projects: `vp create`\n\n`vp create` scaffolds a new project and optionally sets up the full hooks pipeline:\n\n1. Prompts \"Set up pre-commit hooks?\" (default: yes)\n2. If accepted, calls `installGitHooks()` which:\n   - Adds `\"prepare\": \"vp config\"` to `package.json`\n   - Adds `staged` config to `vite.config.ts`\n   - Creates `.vite-hooks/pre-commit` containing `vp staged`\n   - Runs `vp config --hooks-only` to install hook shims and set `core.hooksPath`\n\nFlags: `--hooks` (force), `--no-hooks` (skip)\n\n### Existing projects: `vp migrate`\n\n`vp migrate` migrates from husky/lint-staged and sets up the full hooks pipeline:\n\n1. Runs pre-flight checks (husky version, other tools, subdirectory detection)\n2. Prompts \"Set up pre-commit hooks?\" (default: yes)\n3. If accepted, after migration rewrite, calls `installGitHooks()` which:\n   - Detects old husky directory from `scripts.prepare`\n   - Rewrites `\"prepare\": \"husky\"` → `\"prepare\": \"vp config\"` via `rewritePrepareScript()`\n   - Migrates `lint-staged` config from `package.json` to `staged` in `vite.config.ts`\n   - Copies `.husky/` hooks to `.vite-hooks/` (or preserves custom dir)\n   - Creates `.vite-hooks/pre-commit` containing `vp staged`\n   - Runs `vp config --hooks-only` to install hook shims and set `core.hooksPath`\n   - Removes husky and lint-staged from `devDependencies`\n\nFlags: `--hooks` (force), `--no-hooks` (skip)\n\n### Ongoing use: `vp config` (prepare lifecycle)\n\n`vp config` is the command that runs on every `npm install` via the `prepare` script. It reinstalls hook shims — it does **not** create the `staged` config or the pre-commit hook file. Those are created by `vp create`/`vp migrate`.\n\n```json\n{ \"scripts\": { \"prepare\": \"vp config\" } }\n```\n\nWhen `npm_lifecycle_event=prepare` (set by npm/pnpm/yarn during `npm install`), agent setup is skipped automatically — only hooks are reinstalled.\n\n### Manual setup (without `vp create`/`vp migrate`)\n\nFor users who want to set up hooks manually, four steps are required:\n\n1. **Add prepare script** to `package.json`:\n   ```json\n   { \"scripts\": { \"prepare\": \"vp config\" } }\n   ```\n2. **Add staged config** to `vite.config.ts`:\n   ```typescript\n   export default defineConfig({\n     staged: { '*': 'vp check --fix' },\n   });\n   ```\n3. **Create pre-commit hook** at `.vite-hooks/pre-commit`:\n   ```sh\n   vp staged\n   ```\n4. **Run `vp config`** to install hook shims and set `core.hooksPath`\n\n## Commands\n\n### `vp config`\n\n```bash\nvp config                           # Configure project (hooks + agent integration)\nvp config -h                        # Show help\nvp config --hooks-dir .husky        # Custom hooks directory (default: .vite-hooks)\n```\n\nBehavior:\n\n1. Built-in husky-compatible install logic (reimplementation of husky v9, not a bundled dependency)\n2. Sets `core.hooksPath` to `<hooks-dir>/_` (default: `.vite-hooks/_`)\n3. Creates hook scripts in `<hooks-dir>/_/` that source the user-defined hooks in `<hooks-dir>/`\n4. Agent instructions: silently updates existing files that contain Vite+ markers (`<!--VITE PLUS START-->`) when content is outdated. Never creates new agent files. Skipped with `--hooks-only`.\n5. Safe to run multiple times (idempotent)\n6. Exits 0 and skips hooks if `VITE_GIT_HOOKS=0` or `HUSKY=0` environment variable is set (backwards compatible)\n7. Exits 0 and skips hooks if `.git` directory doesn't exist (safe during `npm install` in consumer projects)\n8. Exits 1 on real errors (git command not found, `git config` failed)\n9. Agent update runs uniformly in all modes (`prepare`, interactive, non-interactive). New agent file creation is handled by `vp create`/`vp migrate`.\n10. Interactive mode: prompts on first run for hooks setup\n11. Non-interactive mode: sets up hooks by default\n\n### `vp staged`\n\n```bash\nvp staged                           # Run staged linters on staged files\n```\n\nBehavior:\n\n1. Reads config from `staged` key in `vite.config.ts` via `resolveConfig()`\n2. If `staged` key not found, exits with a warning and setup instructions\n3. Passes config to bundled lint-staged via its programmatic API\n4. Runs configured commands on git-staged files only\n5. Exits with non-zero code if any command fails\n6. Does not support custom config file paths — config must be in `vite.config.ts`\n\nBoth commands are listed under \"Core Commands\" in `vp -h` (global and local CLI).\n\n## Configuration\n\n### `vite.config.ts`\n\n```typescript\nexport default defineConfig({\n  staged: {\n    '*': 'vp check --fix',\n  },\n});\n```\n\n`vp staged` reads config from the `staged` key via Vite's `resolveConfig()`. If no `staged` key is found, it exits with a warning and instructions to add the config. Standalone config files (`.lintstagedrc.*`, `lint-staged.config.*`) are not supported by the migration — projects using those formats are warned to migrate manually.\n\n### `package.json`\n\n```json\n// New project\n{\n  \"scripts\": {\n    \"prepare\": \"vp config\"\n  }\n}\n```\n\n```json\n// Migrated from husky with custom dir — dir is preserved\n{\n  \"scripts\": {\n    \"prepare\": \"vp config --hooks-dir .config/husky\"\n  }\n}\n```\n\nIf the project already has a prepare script, `vp config` is prepended:\n\n```json\n{\n  \"scripts\": {\n    \"prepare\": \"vp config && npm run build\"\n  }\n}\n```\n\n### `.vite-hooks/pre-commit`\n\n```\nvp staged\n```\n\n### Why `*` glob\n\n`vp check --fix` already handles unsupported file types gracefully (it only processes files that match known extensions). Using `*` simplifies the configuration — no need to maintain a list of extensions.\n\n## Automatic Setup\n\nBoth `vp create` and `vp migrate` prompt the user before setting up pre-commit hooks:\n\n- **Interactive mode**: Shows a `prompts.confirm()` prompt: \"Set up pre-commit hooks to run formatting, linting, and type checking with auto-fixes?\" (default: yes)\n- **Non-interactive mode**: Defaults to yes (hooks are set up automatically)\n- **`--hooks` flag**: Force hooks setup (no prompt)\n- **`--no-hooks` flag**: Skip hooks setup entirely (no prompt)\n\n```bash\nvp create --hooks           # Force hooks setup\nvp create --no-hooks        # Skip hooks setup\nvp migrate --hooks          # Force hooks setup\nvp migrate --no-hooks       # Skip hooks setup\n```\n\n### `vp create`\n\n- After project creation and migration rewrite, prompts for hooks setup\n- If accepted, calls `rewritePrepareScript()` then `setupGitHooks()` — same as `vp migrate`\n- `rewritePrepareScript()` rewrites any template-provided `\"prepare\": \"husky\"` to `\"prepare\": \"vp config\"` before `setupGitHooks()` runs\n- Creates `.vite-hooks/pre-commit` with `vp staged`\n\n### `vp migrate`\n\nMigration rewrite (`rewritePackageJson`) uses `vite-tools.yml` rules to rewrite tool commands (vite, oxlint, vitest, etc.) in all scripts. Crucially, the husky rule is **not** in `vite-tools.yml` — it lives in a separate `vite-prepare.yml` and is only applied to `scripts.prepare` via `rewritePrepareScript()`. This ensures husky is never accidentally rewritten in non-prepare scripts.\n\n- Prompts for hooks setup **before** migration rewrite\n- If `--no-hooks`: `rewritePrepareScript()` is never called, so the prepare script stays as-is (e.g. `\"husky\"` remains `\"husky\"`). No undo logic needed.\n- If hooks enabled but Husky v8 detected: warns, sets `shouldSetupHooks = false` and `skipStagedMigration = true` **before** migration rewrite, so lint-staged config is preserved\n- If hooks enabled: after migration rewrite, calls `rewritePrepareScript()` then `setupGitHooks()`\n\nHook setup behavior:\n\n- **No hooks configured** — adds full setup (prepare script + staged config in vite.config.ts + .vite-hooks/pre-commit)\n- **Has husky (default dir)** — `rewritePrepareScript()` rewrites `\"prepare\": \"husky\"` to `\"prepare\": \"vp config\"`, `setupGitHooks()` copies `.husky/` hooks to `.vite-hooks/` and removes husky from devDeps\n- **Has husky (custom dir)** — `rewritePrepareScript()` preserves the custom dir as `\"vp config --hooks-dir .config/husky\"`, `setupGitHooks()` keeps hooks in the custom dir (no copy)\n- **Has `husky install`** — `rewritePrepareScript()` collapses `\"husky install\"` → `\"husky\"` before applying the ast-grep rule, so `\"husky install .hooks\"` becomes `\"vp config --hooks-dir .hooks\"` (custom dir preserved)\n- **Has existing prepare script** (e.g. `\"npm run build\"`) — composes as `\"vp config && npm run build\"` (prepend so hooks are active before other prepare tasks; idempotent if already contains `vp config`)\n- **Has lint-staged** — migrates `\"lint-staged\"` key to `staged` in vite.config.ts, keeps existing config (already rewritten by migration rules), removes lint-staged from devDeps\n\n## Migration Edge Cases\n\n- **Has husky <9.0.0** — detected **before** migration rewrite. Warns \"please upgrade to husky v9+ first\", skips hooks setup, and also skips lint-staged migration (`skipStagedMigration` flag). This preserves the `lint-staged` config in package.json and standalone config files, since `.husky/pre-commit` still references `npx lint-staged`.\n- **Has other tool (simple-git-hooks, lefthook, yorkie)** — warns and skips\n- **Subdirectory project** (e.g. `vp migrate foo`) — if the project path differs from the git root, warns \"Subdirectory project detected\" and skips hooks setup entirely. This prevents `vp config` from setting `core.hooksPath` to a subdirectory path, which would hijack the repo-wide hooks.\n- **No .git directory** — adds package.json config and creates hook pre-commit file, but skips `vp config` hook install (no `core.hooksPath` to set)\n- **Standalone lint-staged config** (`.lintstagedrc.*`, `lint-staged.config.*`) — not supported by auto-migration. Projects using those formats are warned to migrate manually.\n- After creating the pre-commit hook, runs `vp config` directly to install hook shims (does not rely on npm install lifecycle, which may not run in CI or snap test contexts)\n\n## Implementation Architecture\n\n### Rust Global CLI\n\nBoth commands follow Category B (JS Script Commands) pattern — same as `vp create` and `vp migrate`:\n\n```rust\n// crates/vite_global_cli/src/commands/config.rs\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    super::delegate::execute(cwd, \"config\", args).await\n}\n\n// crates/vite_global_cli/src/commands/staged.rs\npub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {\n    super::delegate::execute(cwd, \"staged\", args).await\n}\n```\n\n### JavaScript Side\n\nEntry points bundled by rolldown into `dist/global/`:\n\n- `src/config/bin.ts` — unified configuration: hooks setup (husky-compatible) + agent integration\n- `src/staged/bin.ts` — imports lint-staged programmatic API, reads `staged` config from vite.config.ts\n- `src/migration/bin.ts` — migration flow, calls `rewritePrepareScript()` + `setupGitHooks()`\n\n### AST-grep Rules\n\n- `rules/vite-tools.yml` — rewrites tool commands (vite, oxlint, vitest, lint-staged, tsdown) in **all** scripts\n- `rules/vite-prepare.yml` — rewrites `husky` → `vp config`, applied **only** to `scripts.prepare` via `rewritePrepareScript()`\n\nThe separation ensures the husky rule is never applied to non-prepare scripts (e.g. a hypothetical `\"postinstall\": \"husky something\"` won't be touched). The `husky install` → `husky` collapsing (needed because ast-grep can't match multi-word commands in bash) is done in TypeScript before applying the rule. After the AST-grep rewrite, post-processing handles the dir arg: custom dirs get `--hooks-dir` flags, while the default `.husky` dir is dropped (hooks are copied to `.vite-hooks/` instead).\n\n### Build\n\nlint-staged is a devDependency of the `vite-plus` package, bundled by rolldown at build time into `dist/global/`. husky is not a dependency — `vp config` is a standalone reimplementation of husky v9's install logic.\n\n### Why husky cannot be bundled\n\nhusky v9's `install()` function uses `new URL('husky', import.meta.url)` to resolve and `copyFileSync` its shell script (the hook dispatcher) relative to its own source location. When bundled by rolldown, `import.meta.url` points to the bundled output directory, not the original `node_modules/husky/` directory, so the shell script file cannot be found at runtime. Rather than working around this with asset copying hacks, `vp config` inlines the equivalent shell script as a string constant and writes it directly via `writeFileSync`.\n\nHusky <9.0.0 is not supported by auto migration — `vp migrate` detects unsupported versions and skips hooks setup with a warning.\n\n## Relationship to Existing Commands\n\n| Command          | Purpose                                | When                        |\n| ---------------- | -------------------------------------- | --------------------------- |\n| `vp check`       | Format + lint + type check             | Manual or CI                |\n| `vp check --fix` | Auto-fix format + lint issues          | Manual or pre-commit        |\n| **`vp config`**  | **Reinstall hook shims + agent setup** | **npm `prepare` lifecycle** |\n| **`vp staged`**  | **Run staged linters on staged files** | **Pre-commit hook**         |\n\n## Comparison with Other Tools\n\n| Tool                      | Approach                                   |\n| ------------------------- | ------------------------------------------ |\n| husky + lint-staged       | Separate devDependencies, manual setup     |\n| simple-git-hooks          | Lightweight alternative to husky           |\n| lefthook                  | Go binary, config-file based               |\n| **vp config + vp staged** | **Built-in, zero-config, automatic setup** |\n"
  },
  {
    "path": "rfcs/dedupe-package-command.md",
    "content": "# RFC: Vite+ Dedupe Package Command\n\n## Summary\n\nAdd `vp dedupe` command that automatically adapts to the detected package manager (pnpm/npm/yarn) for optimizing dependency trees by removing duplicate packages and upgrading older dependencies to newer compatible versions in the lockfile. This helps reduce redundancy and improve project efficiency.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands to deduplicate dependencies:\n\n```bash\npnpm dedupe\nnpm dedupe\nyarn dedupe  # yarn@2+ only\n```\n\nThis creates friction in dependency management workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify dependency optimization**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm dedupe                    # pnpm project\nnpm dedupe                     # npm project\nyarn dedupe                    # yarn@2+ project\n\n# Different check modes\npnpm dedupe --check            # pnpm - check without modifying\nnpm dedupe --dry-run           # npm - check without modifying\nyarn dedupe --check            # yarn@2+ - check without modifying\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp dedupe                    # Deduplicate dependencies\n\n# Check mode (dry-run)\nvp dedupe --check            # Check if deduplication would make changes\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n#### Dedupe Command\n\n```bash\nvp dedupe [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Basic deduplication\nvp dedupe\n\n# Check mode (preview changes without modifying)\nvp dedupe --check\n```\n\n### Command Mapping\n\n#### Dedupe Command Mapping\n\n**pnpm references:**\n\n- https://pnpm.io/cli/dedupe\n- Performs an install removing older dependencies in the lockfile if a newer version can be used\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-dedupe\n- Reduces duplication in the package tree by removing redundant packages\n\n**yarn references:**\n\n- https://yarnpkg.com/cli/dedupe (yarn@2+)\n- Note: yarn@2+ has a dedicated `yarn dedupe` command with `--check` mode support\n\n| Vite+ Flag  | pnpm          | npm          | yarn@2+       | Description                  |\n| ----------- | ------------- | ------------ | ------------- | ---------------------------- |\n| `vp dedupe` | `pnpm dedupe` | `npm dedupe` | `yarn dedupe` | Deduplicate dependencies     |\n| `--check`   | `--check`     | `--dry-run`  | `--check`     | Check if changes would occur |\n\n**Note**:\n\n- pnpm uses `--check` for dry-run, npm uses `--dry-run`, yarn@2+ uses `--check`\n- yarn@1 does not have dedupe command and is not supported\n\n### Dedupe Behavior Differences Across Package Managers\n\n#### pnpm\n\n**Dedupe behavior:**\n\n- Scans the lockfile (`pnpm-lock.yaml`) for duplicate dependencies\n- Upgrades older versions to newer compatible versions where possible\n- Removes redundant entries in the lockfile\n- Performs a fresh install with optimized dependencies\n- `--check` flag previews changes without modifying files\n\n**Exit codes:**\n\n- 0: Success or no changes needed\n- Non-zero: Changes would be made (when using `--check`)\n\n#### npm\n\n**Dedupe behavior:**\n\n- Searches the local package tree (`node_modules`) for duplicate packages\n- Attempts to simplify the structure by moving dependencies up the tree\n- Removes duplicate packages where semver allows\n- Modifies both `node_modules` and `package-lock.json`\n- `--dry-run` shows what would be done without making changes\n\n**Exit codes:**\n\n- 0: Success\n- Non-zero: Error occurred\n\n#### yarn@2+ (Berry)\n\n**Dedupe behavior:**\n\n- Has a dedicated `yarn dedupe` command\n- Scans the lockfile (`yarn.lock`) for duplicate dependencies\n- Deduplicates packages by removing redundant entries\n- `--check` flag previews changes without modifying files\n- Uses Plug'n'Play or node_modules depending on configuration\n\n**Exit codes:**\n\n- 0: Success or no changes needed\n- Non-zero: Changes would be made (when using `--check`)\n\n**Note**: yarn@1 does not have a dedupe command and is not supported by Vite+\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command variant:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Deduplicate dependencies by removing older versions\n    #[command(disable_help_flag = true)]\n    Dedupe {\n        /// Check if deduplication would make changes (pnpm: --check, npm: --dry-run)\n        #[arg(long)]\n        check: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/commands/dedupe.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\n#[derive(Debug, Default)]\npub struct DedupeCommandOptions<'a> {\n    pub check: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the dedupe command with the package manager.\n    #[must_use]\n    pub async fn run_dedupe_command(\n        &self,\n        options: &DedupeCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_dedupe_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the dedupe command.\n    #[must_use]\n    pub fn resolve_dedupe_command(&self, options: &DedupeCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"dedupe\".into());\n\n                // pnpm uses --check for dry-run\n                if options.check {\n                    args.push(\"--check\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                args.push(\"dedupe\".into());\n\n                // yarn@2+ supports --check\n                if options.check {\n                    args.push(\"--check\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"dedupe\".into());\n\n                if options.check {\n                    args.push(\"--dry-run\".into());\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n```\n\n**File**: `crates/vite_package_manager/src/commands/mod.rs`\n\nUpdate to include dedupe module:\n\n```rust\npub mod add;\nmod install;\npub mod remove;\npub mod update;\npub mod link;\npub mod unlink;\npub mod dedupe;  // Add this line\n```\n\n#### 3. Dedupe Command Implementation\n\n**File**: `crates/vite_task/src/dedupe.rs` (new file)\n\n```rust\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_package_manager::{\n    PackageManager,\n    commands::dedupe::DedupeCommandOptions,\n};\nuse vite_workspace::Workspace;\n\npub struct DedupeCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl DedupeCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        check: bool,\n        extra_args: Vec<String>,\n    ) -> Result<ExitStatus, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n\n        // Build dedupe command options\n        let dedupe_options = DedupeCommandOptions {\n            check,\n            pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) },\n        };\n\n        let exit_status = package_manager\n            .run_dedupe_command(&dedupe_options, &self.workspace_root)\n            .await?;\n\n        if !exit_status.success() {\n            if check {\n                eprintln!(\"Deduplication would result in changes\");\n            }\n            return Err(Error::CommandFailed {\n                command: \"dedupe\".to_string(),\n                exit_code: exit_status.code(),\n            });\n        }\n\n        Ok(exit_status)\n    }\n}\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache dedupe operations.\n\n**Rationale**:\n\n- Dedupe modifies lockfiles and dependency trees\n- Side effects make caching inappropriate\n- Each execution should analyze current state\n- Similar to how install/add/remove work\n\n### 2. Simplified Flag Support\n\n**Decision**: Only support `--check` flag for dry-run validation.\n\n**Rationale**:\n\n- Keeps the command simple and focused\n- pnpm and yarn@2+ use `--check`, npm uses `--dry-run`\n- Unified flag that maps to appropriate package manager flag\n- Additional workspace/filtering flags add unnecessary complexity\n\n### 3. yarn Support\n\n**Decision**: Only support yarn@2+, not yarn@1.\n\n**Rationale**:\n\n- yarn@2+ has dedicated `yarn dedupe` command with `--check` support\n- yarn@1 does not have a dedupe command (per official documentation)\n- Simplifies implementation by not requiring version detection\n- Aligns with official yarn documentation\n\n### 4. Exit Code Handling\n\n**Decision**: Return non-zero exit code when `--check` detects changes.\n\n**Rationale**:\n\n- Matches pnpm behavior\n- Useful for CI/CD pipelines\n- Can validate if deduplication is needed\n- Standard practice for check/dry-run modes\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vp dedupe\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### Check Mode Detects Changes\n\n```bash\n$ vp dedupe --check\nChecking if deduplication would make changes...\nChanges detected. Run 'vp dedupe' to apply.\nExit code: 1\n```\n\n### Unsupported Flag Warning\n\n```bash\n$ vp dedupe --filter app\nWarning: --filter not supported by npm, use --workspace instead\nRunning: npm dedupe\n```\n\n## User Experience\n\n### Success Output\n\n```bash\n$ vp dedupe\nDetected package manager: pnpm@10.15.0\nRunning: pnpm dedupe\n\nPackages: -15\n-15\nProgress: resolved 250, reused 235, downloaded 0, added 0, done\n\nDependencies optimized. Removed 15 duplicate packages.\n\nDone in 3.2s\n```\n\n```bash\n$ vp dedupe --check\nDetected package manager: pnpm@10.15.0\nRunning: pnpm dedupe --check\n\nWould deduplicate 8 packages:\n  - lodash: 4.17.20 → 4.17.21 (3 occurrences)\n  - react: 18.2.0 → 18.3.1 (2 occurrences)\n  - typescript: 5.3.0 → 5.5.0 (3 occurrences)\n\nRun 'vp dedupe' to apply these changes.\nExit code: 1\n```\n\n```bash\n$ vp dedupe --check\nDetected package manager: npm@11.0.0\nRunning: npm dedupe --dry-run\n\nremoved 12 packages\nupdated 5 packages\n\nThis was a dry run. No changes were made.\n\nDone in 4.5s\n```\n\n### Yarn@2+ Output\n\n```bash\n$ vp dedupe\nDetected package manager: yarn@4.0.0\nRunning: yarn dedupe\n\n➤ YN0000: ┌ Resolution step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Fetch step\n➤ YN0000: └ Completed\n➤ YN0000: ┌ Link step\n➤ YN0000: └ Completed\n➤ YN0000: Done in 1.2s\n\nDone in 1.2s\n```\n\n```bash\n$ vp dedupe --check\nDetected package manager: yarn@4.0.0\nRunning: yarn dedupe --check\n\n➤ YN0000: Found 5 packages with duplicates\n➤ YN0000: Run 'yarn dedupe' to apply changes\n\nExit code: 1\n```\n\n### No Changes Needed\n\n```bash\n$ vp dedupe\nDetected package manager: pnpm@10.15.0\nRunning: pnpm dedupe\n\nAlready up-to-date\n\nDone in 0.8s\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Error on Unsupported Flags\n\n```bash\nvp dedupe --filter app  # on npm\nError: --filter flag not supported by npm\n```\n\n**Rejected because**:\n\n- Too strict, prevents usage\n- Better to warn and continue\n- Users might have wrapper scripts\n- Graceful degradation is preferred\n\n### Alternative 2: Auto-Translate All Flags\n\n```bash\nvp dedupe --filter app  # on npm\n# Automatically translates to: npm dedupe --workspace app\n```\n\n**Rejected because**:\n\n- Different semantics between --filter and --workspace\n- pnpm's --filter supports patterns, npm's --workspace doesn't\n- Could lead to unexpected behavior\n- Better to warn and let user adjust\n\n### Alternative 3: Separate Check Command\n\n```bash\nvp dedupe:check\nvp dedupe:run\n```\n\n**Rejected because**:\n\n- More commands to remember\n- Flags are more idiomatic\n- Matches native package manager APIs\n- Less intuitive than `--check` flag\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Dedupe` command variant to `Commands` enum\n2. Create `dedupe.rs` module in both crates\n3. Implement package manager command resolution\n4. Add basic error handling\n\n### Phase 2: Advanced Features\n\n1. Implement check/dry-run mode\n2. Add workspace filtering support\n3. Implement npm's dependency type filtering\n4. Handle yarn@2+ special case\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Integration tests with mock package managers\n3. Test check mode behavior\n4. Test workspace operations\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document package manager compatibility\n4. Add CI/CD usage examples\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x (WIP)\n- pnpm@10.x\n- yarn@4.x (yarn@2+)\n- npm@10.x\n- npm@11.x (WIP)\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_dedupe_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_dedupe_command(&DedupeCommandOptions {\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"dedupe\"]);\n}\n\n#[test]\nfn test_pnpm_dedupe_check() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_dedupe_command(&DedupeCommandOptions {\n        check: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"dedupe\", \"--check\"]);\n}\n\n#[test]\nfn test_npm_dedupe_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_dedupe_command(&DedupeCommandOptions {\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"dedupe\"]);\n}\n\n#[test]\nfn test_npm_dedupe_check() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_dedupe_command(&DedupeCommandOptions {\n        check: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"dedupe\", \"--dry-run\"]);\n}\n\n#[test]\nfn test_yarn_dedupe_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let args = pm.resolve_dedupe_command(&DedupeCommandOptions {\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"dedupe\"]);\n}\n\n#[test]\nfn test_yarn_dedupe_check() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let args = pm.resolve_dedupe_command(&DedupeCommandOptions {\n        check: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"dedupe\", \"--check\"]);\n}\n```\n\n### Integration Tests\n\nCreate fixtures for testing with each package manager:\n\n```\nfixtures/dedupe-test/\n  pnpm-workspace.yaml\n  package.json\n  packages/\n    app/\n      package.json (with duplicate deps)\n    utils/\n      package.json (with duplicate deps)\n  test-steps.json\n```\n\nTest cases:\n\n1. Basic deduplication\n2. Check mode without modifying\n3. Exit code verification for check mode\n4. Pass-through arguments handling\n5. Package manager detection and command mapping\n\n## CLI Help Output\n\n```bash\n$ vp dedupe --help\nDeduplicate dependencies by removing older versions\n\nUsage: vp dedupe [OPTIONS] [-- <PASS_THROUGH_ARGS>...]\n\nOptions:\n  --check                    Check if deduplication would make changes\n                             (pnpm: --check, npm: --dry-run, yarn@2+: --check)\n\nBehavior by Package Manager:\n  pnpm:    Removes older dependencies from lockfile, upgrades to newer compatible versions\n  npm:     Reduces duplication in package tree by moving dependencies up the tree\n  yarn@2+: Scans lockfile and removes duplicate package entries\n\nNote: yarn@1 does not have a dedupe command and is not supported\n\nExamples:\n  vp dedupe                          # Deduplicate all dependencies\n  vp dedupe --check                  # Check if changes would occur\n  vp dedupe -- --some-flag           # Pass custom flags to package manager\n```\n\n## Performance Considerations\n\n1. **No Caching**: Operations run directly without cache overhead\n2. **Lockfile Analysis**: Fast lockfile parsing and optimization\n3. **Single Execution**: Unlike task runner, these are one-off operations\n4. **Auto-Detection**: Reuses existing package manager detection (already cached)\n5. **CI/CD Optimization**: Check mode enables quick validation without full install\n\n## Security Considerations\n\n1. **Lockfile Integrity**: Maintains lockfile integrity while optimizing\n2. **Version Constraints**: Respects semver constraints from package.json\n3. **No Unexpected Upgrades**: Only deduplicates within allowed version ranges\n4. **Audit Compatibility**: Works with audit commands to ensure security\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New command is additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Migration Path\n\n### Adoption\n\nUsers can start using immediately:\n\n```bash\n# Old way\npnpm dedupe\nnpm dedupe\n\n# New way (works with any package manager)\nvp dedupe\n```\n\n### CI/CD Integration\n\n```yaml\n# Before\n- run: pnpm dedupe --check\n\n# After (works with any package manager)\n- run: vp dedupe --check\n```\n\n## Real-World Usage Examples\n\n### Local Development\n\n```bash\n# After installing many packages over time\nvp dedupe                     # Clean up duplicates\n\n# Check if cleanup is needed\nvp dedupe --check             # Preview changes\n```\n\n### CI/CD Pipeline\n\n```yaml\nname: Check Dependency Optimization\non: [pull_request]\n\njobs:\n  dedupe-check:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: vp install\n      - run: vp dedupe --check\n        name: Verify dependencies are optimized\n```\n\n### Post-Update Workflow\n\n```bash\n# Update dependencies\nvp update --latest\n\n# Deduplicate after updates\nvp dedupe\n\n# Verify everything still works\nvp test\n```\n\n## Package Manager Compatibility\n\n| Feature       | pnpm         | npm            | yarn@2+      | Notes                                     |\n| ------------- | ------------ | -------------- | ------------ | ----------------------------------------- |\n| Basic dedupe  | ✅ `dedupe`  | ✅ `dedupe`    | ✅ `dedupe`  | All use native dedupe command             |\n| Check/Dry-run | ✅ `--check` | ✅ `--dry-run` | ✅ `--check` | npm uses different flag name              |\n| Exit codes    | ✅ Supported | ✅ Supported   | ✅ Supported | All return non-zero on check with changes |\n\n**Note**: yarn@1 does not have a dedupe command and is not supported\n\n## Future Enhancements\n\n### 1. Dedupe Report\n\nGenerate detailed report of deduplication changes:\n\n```bash\nvp dedupe --report\n\n# Output:\nDeduplication Report:\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPackage         Old Version    New Version    Occurrences\nlodash          4.17.20        4.17.21        3\nreact           18.2.0         18.3.1         2\ntypescript      5.3.0          5.5.0          3\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nTotal: 8 packages deduplicated\n```\n\n### 2. Auto-Dedupe on Install\n\nAutomatically deduplicate after install:\n\n```bash\nvp install --auto-dedupe\n\n# Or configure in vite-task.json\n{\n  \"options\": {\n    \"autoDedupe\": true\n  }\n}\n```\n\n### 3. Dedupe Policy Checking\n\nEnforce deduplication policies in CI:\n\n```bash\nvp dedupe --policy strict  # Fail if any duplicates exist\nvp dedupe --policy warn    # Warn but don't fail\n```\n\n### 4. Dependency Analysis\n\nShow why packages are duplicated:\n\n```bash\nvp dedupe --why lodash\n\n# Output:\nlodash@4.17.20:\n  - Required by: package-a@1.0.0 (via ^4.17.0)\n  - Required by: package-b@2.0.0 (via ~4.17.20)\n\nlodash@4.17.21:\n  - Required by: package-c@3.0.0 (via ^4.17.21)\n\nRecommendation: All can use lodash@4.17.21\n```\n\n## Open Questions\n\n1. **Should we auto-run dedupe after updates?**\n   - Proposed: No, keep commands separate\n   - Users can combine: `vp update && vp dedupe`\n   - Later: Add `--auto-dedupe` flag to update command\n\n2. **Should we show detailed diff in check mode?**\n   - Proposed: Yes, show what would change\n   - Helps users understand impact\n   - Use package manager's native output\n\n3. **Should we support force dedupe (ignore semver)?**\n   - Proposed: No, too risky\n   - Could break compatibility\n   - Let package managers handle constraints\n\n4. **Should we warn about security vulnerabilities during dedupe?**\n   - Proposed: Later enhancement\n   - Run audit after dedupe\n   - Integrate with existing audit tools\n\n5. **Should we support interactive mode?**\n   - Proposed: Later enhancement\n   - Let users choose which packages to dedupe\n   - Similar to `vp update --interactive`\n\n## Success Metrics\n\n1. **Adoption**: % of users using `vp dedupe` vs direct package manager\n2. **Dependency Reduction**: Average reduction in duplicate packages\n3. **CI Integration**: Usage in CI/CD pipelines for validation\n4. **Error Rate**: Track command failures vs package manager direct usage\n\n## Conclusion\n\nThis RFC proposes adding `vp dedupe` command to provide a unified interface for dependency deduplication across pnpm/npm/yarn@2+. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports check mode for validation (maps to --check for pnpm/yarn@2+, --dry-run for npm)\n- ✅ Simple, focused API with only essential --check flag\n- ✅ yarn@2+ support with native dedupe command\n- ✅ Pass-through args for advanced use cases\n- ✅ No caching overhead\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ CI/CD friendly with exit codes\n- ✅ Extensible for future enhancements\n\nThe implementation follows the same patterns as other package management commands while providing a simple, unified interface for dependency deduplication. By focusing only on the essential --check flag, the command remains easy to use and understand.\n"
  },
  {
    "path": "rfcs/dlx-command.md",
    "content": "# RFC: Vite+ dlx Command\n\n## Summary\n\nAdd `vp dlx` command that fetches a package from the registry without installing it as a dependency, hotloads it, and runs whatever default command binary it exposes. This provides a unified interface across pnpm, npm, and yarn for executing remote packages temporarily.\n\n## Motivation\n\nCurrently, developers must use package manager-specific commands for executing remote packages:\n\n```bash\n# pnpm\npnpm dlx create-react-app my-app\npnpm dlx typescript tsc --version\n\n# npm\nnpx create-react-app my-app\nnpm exec -- create-react-app my-app\n\n# yarn (v2+ only)\nyarn dlx create-react-app my-app\n```\n\nThis creates several issues:\n\n1. **Cognitive Load**: Developers must remember different commands for each package manager\n2. **Context Switching**: When working across projects with different package managers, developers need to switch mental models\n3. **Script Portability**: Scripts that use dlx-like commands are tied to a specific package manager\n4. **Yarn 1.x Incompatibility**: Yarn Classic doesn't have a `dlx` command at all, requiring fallback to `npx`\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm dlx create-vue my-app          # pnpm project\nnpx create-vue my-app               # npm project\nyarn dlx create-vue my-app          # yarn@2+ project (doesn't work in yarn@1)\n\n# Different syntax for specifying packages\npnpm --package=typescript dlx tsc --version\nnpm exec --package=typescript -- tsc --version\nyarn dlx -p typescript tsc --version\n\n# Shell mode has different flags\npnpm dlx -c 'echo \"hello\" | cowsay'\nnpm exec -c 'echo \"hello\" | cowsay'\nyarn dlx -c 'echo \"hello\" | cowsay'  # Not supported in yarn\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp dlx create-vue my-app\nvp dlx typescript tsc --version\nvp dlx --package yo --package generator-webapp yo webapp\nvp dlx -c 'echo \"hello\" | cowsay'\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n```bash\nvp dlx [OPTIONS] <package[@version]> [args...]\n```\n\n**Options:**\n\n- `--package, -p <name>`: Specifies which package(s) to install before running the command. Can be specified multiple times.\n- `--shell-mode, -c`: Executes the command within a shell environment (`/bin/sh` on UNIX, `cmd.exe` on Windows).\n- `--silent, -s`: Suppresses all output except the executed command's output.\n\n### Usage Examples\n\n```bash\n# Basic usage - run a package's default binary\nvp dlx create-vue my-app\n\n# Specify version\nvp dlx create-vue@3.10.0 my-app\nvp dlx typescript@5.5.4 tsc --version\n\n# Separate package and command (when binary name differs from package name)\nvp dlx --package @pnpm/meta-updater meta-updater --help\n\n# Multiple packages\nvp dlx --package yo --package generator-webapp yo webapp --skip-install\n\n# Shell mode (pipe commands)\nvp dlx --package cowsay --package lolcatjs -c 'echo \"hi vite\" | cowsay | lolcatjs'\n\n# Silent mode\nvp dlx -s create-vue my-app\n\n# Combine options\nvp dlx -p typescript -p @types/node -c 'tsc --init && node -e \"console.log(123)\"'\n```\n\n### Command Mapping\n\n**References:**\n\n- pnpm: https://pnpm.io/cli/dlx\n- npm: https://docs.npmjs.com/cli/v10/commands/npm-exec\n- yarn: https://yarnpkg.com/cli/dlx\n\n| Vite+ Flag                      | pnpm               | npm                 | yarn@1      | yarn@2+          | Description                |\n| ------------------------------- | ------------------ | ------------------- | ----------- | ---------------- | -------------------------- |\n| `vp dlx <pkg>`                  | `pnpm dlx <pkg>`   | `npm exec <pkg>`    | `npx <pkg>` | `yarn dlx <pkg>` | Execute package binary     |\n| `--package <name>`, `-p <name>` | `--package <name>` | `--package=<name>`  | N/A         | `-p <name>`      | Specify package to install |\n| `--shell-mode`, `-c`            | `-c`               | `-c`                | N/A         | N/A              | Execute in shell           |\n| `--silent`, `-s`                | `--silent`         | `--loglevel silent` | `--quiet`   | `--quiet`        | Suppress output            |\n\n**Notes:**\n\n- **yarn@1 (Classic)**: Does not have a native `dlx` command. Falls back to using `npx` which comes bundled with npm.\n- **npm exec vs npx**: `npx` is essentially an alias for `npm exec --` with some convenience features. We use `npm exec` for consistency.\n- **Shell mode**: Yarn 2+ does not support shell mode (`-c`), command will print a warning and try to execute anyway.\n- **--package flag position**: For pnpm, `--package` comes before `dlx`. For npm, `--package` can be anywhere. For yarn, `-p` comes after `dlx`.\n- **Auto-confirm prompts**: For npm and npx (yarn@1 fallback), `--yes` is automatically added to align with pnpm's behavior which doesn't require confirmation.\n\n### Argument Handling\n\nThe `dlx` command has specific argument parsing requirements:\n\n```bash\n# Everything after the package spec is passed to the executed command\nvp dlx typescript tsc --version --help\n\n# This runs: tsc --version --help\n# NOT: typescript with vp dlx options --version --help\n```\n\n**Implementation approach:**\n\n1. Parse known vp dlx options (`--package`, `-c`, `-s`)\n2. First non-option argument is the package spec (with optional @version)\n3. All remaining arguments are passed through to the executed command\n\n## Implementation Architecture\n\n### 1. Command Structure\n\n**File**: `crates/vite_command/src/lib.rs`\n\nAdd new command:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Execute a package binary without installing it as a dependency\n    #[command(disable_help_flag = true)]\n    Dlx {\n        /// Package(s) to install before running the command\n        /// Can be specified multiple times\n        #[arg(long, short = 'p', value_name = \"NAME\")]\n        package: Vec<String>,\n\n        /// Execute the command within a shell environment\n        #[arg(long = \"shell-mode\", short = 'c')]\n        shell_mode: bool,\n\n        /// Suppress all output except the executed command's output\n        #[arg(long, short = 's')]\n        silent: bool,\n\n        /// Package to execute (with optional @version) and arguments\n        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n        args: Vec<String>,\n    },\n}\n```\n\n### 2. Package Manager Adapter\n\n**File**: `crates/vite_install/src/commands/dlx.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\n/// Options for the dlx command\npub struct DlxCommandOptions<'a> {\n    /// Additional packages to install\n    pub packages: &'a [String],\n    /// The package to execute (first positional arg)\n    pub package_spec: &'a str,\n    /// Arguments to pass to the executed command\n    pub args: &'a [String],\n    /// Execute in shell mode\n    pub shell_mode: bool,\n    /// Suppress output\n    pub silent: bool,\n}\n\nimpl PackageManager {\n    /// Resolve the dlx command for the detected package manager\n    #[must_use]\n    pub fn resolve_dlx_command(&self, options: &DlxCommandOptions) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n\n        match self.client {\n            PackageManagerType::Pnpm => self.resolve_pnpm_dlx(options, envs),\n            PackageManagerType::Npm => self.resolve_npm_dlx(options, envs),\n            PackageManagerType::Yarn => {\n                if self.version.starts_with(\"1.\") {\n                    // Yarn 1.x doesn't have dlx, fall back to npx\n                    self.resolve_npx_fallback(options, envs)\n                } else {\n                    self.resolve_yarn_dlx(options, envs)\n                }\n            }\n        }\n    }\n\n    fn resolve_pnpm_dlx(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        let mut args = Vec::new();\n\n        // Add --package flags before dlx\n        for pkg in options.packages {\n            args.push(\"--package\".into());\n            args.push(pkg.clone());\n        }\n\n        args.push(\"dlx\".into());\n\n        // Add shell mode flag\n        if options.shell_mode {\n            args.push(\"-c\".into());\n        }\n\n        // Add silent flag\n        if options.silent {\n            args.push(\"--silent\".into());\n        }\n\n        // Add package spec\n        args.push(options.package_spec.into());\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n\n        ResolveCommandResult {\n            bin_path: \"pnpm\".into(),\n            args,\n            envs,\n        }\n    }\n\n    fn resolve_npm_dlx(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        let mut args = vec![\"exec\".into()];\n\n        // Add package flags\n        for pkg in options.packages {\n            args.push(format!(\"--package={}\", pkg));\n        }\n\n        // Add the main package as well\n        if !options.packages.is_empty() || options.package_spec.contains('@') {\n            args.push(format!(\"--package={}\", options.package_spec));\n        }\n\n        // Add shell mode flag\n        if options.shell_mode {\n            args.push(\"-c\".into());\n        }\n\n        // Always add --yes to auto-confirm prompts (align with pnpm behavior)\n        args.push(\"--yes\".into());\n\n        // Add silent flag\n        if options.silent {\n            args.push(\"--loglevel\".into());\n            args.push(\"silent\".into());\n        }\n\n        // Add separator and command\n        args.push(\"--\".into());\n\n        // For npm exec, we need to extract the command name from package spec\n        let command = if options.packages.is_empty() {\n            extract_command_from_spec(options.package_spec)\n        } else {\n            options.package_spec.to_string()\n        };\n        args.push(command);\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n\n        ResolveCommandResult {\n            bin_path: \"npm\".into(),\n            args,\n            envs,\n        }\n    }\n\n    fn resolve_yarn_dlx(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        let mut args = vec![\"dlx\".into()];\n\n        // Add package flags\n        for pkg in options.packages {\n            args.push(\"-p\".into());\n            args.push(pkg.clone());\n        }\n\n        // Add quiet flag for silent mode\n        if options.silent {\n            args.push(\"--quiet\".into());\n        }\n\n        // Warn about unsupported shell mode\n        if options.shell_mode {\n            eprintln!(\"Warning: yarn dlx does not support shell mode (-c)\");\n        }\n\n        // Add package spec\n        args.push(options.package_spec.into());\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n\n        ResolveCommandResult {\n            bin_path: \"yarn\".into(),\n            args,\n            envs,\n        }\n    }\n\n    fn resolve_npx_fallback(\n        &self,\n        options: &DlxCommandOptions,\n        envs: HashMap<String, String>,\n    ) -> ResolveCommandResult {\n        eprintln!(\"Note: yarn@1 does not have dlx command, falling back to npx\");\n\n        let mut args = Vec::new();\n\n        // Add package flags\n        for pkg in options.packages {\n            args.push(\"--package\".into());\n            args.push(pkg.clone());\n        }\n\n        // Add shell mode flag\n        if options.shell_mode {\n            args.push(\"-c\".into());\n        }\n\n        // Add quiet flag for silent mode\n        if options.silent {\n            args.push(\"--quiet\".into());\n        }\n\n        // Always add --yes to auto-confirm prompts (align with pnpm behavior)\n        args.push(\"--yes\".into());\n\n        // Add package spec\n        args.push(options.package_spec.into());\n\n        // Add command arguments\n        args.extend(options.args.iter().cloned());\n\n        ResolveCommandResult {\n            bin_path: \"npx\".into(),\n            args,\n            envs,\n        }\n    }\n\n    /// Run the dlx command\n    pub async fn run_dlx_command(\n        &self,\n        options: &DlxCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_dlx_command(options);\n        run_command(\n            &resolve_command.bin_path,\n            &resolve_command.args,\n            &resolve_command.envs,\n            cwd,\n        )\n        .await\n    }\n}\n\n/// Extract command name from package spec\n/// e.g., \"create-vue@3.10.0\" -> \"create-vue\"\nfn extract_command_from_spec(spec: &str) -> String {\n    // Handle scoped packages: @scope/pkg@version -> pkg\n    if spec.starts_with('@') {\n        // Find the second @ (version separator) or use the whole thing\n        if let Some(slash_pos) = spec.find('/') {\n            let after_slash = &spec[slash_pos + 1..];\n            if let Some(at_pos) = after_slash.find('@') {\n                return after_slash[..at_pos].to_string();\n            }\n            return after_slash.to_string();\n        }\n    }\n\n    // Non-scoped: pkg@version -> pkg\n    if let Some(at_pos) = spec.find('@') {\n        return spec[..at_pos].to_string();\n    }\n\n    spec.to_string()\n}\n```\n\n### 3. Command Handler\n\n**File**: `crates/vite_task/src/dlx.rs` (new file)\n\n```rust\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_install::commands::dlx::DlxCommandOptions;\nuse vite_install::PackageManager;\n\npub struct DlxCommand {\n    cwd: AbsolutePathBuf,\n}\n\nimpl DlxCommand {\n    pub fn new(cwd: AbsolutePathBuf) -> Self {\n        Self { cwd }\n    }\n\n    pub async fn execute(\n        self,\n        packages: Vec<String>,\n        shell_mode: bool,\n        silent: bool,\n        args: Vec<String>,\n    ) -> Result<i32, Error> {\n        if args.is_empty() {\n            return Err(Error::InvalidArgument(\n                \"dlx requires a package name\".to_string(),\n            ));\n        }\n\n        // First arg is the package spec, rest are command args\n        let package_spec = &args[0];\n        let command_args = &args[1..];\n\n        let package_manager = PackageManager::builder(&self.cwd).build().await?;\n\n        let options = DlxCommandOptions {\n            packages: &packages,\n            package_spec,\n            args: command_args,\n            shell_mode,\n            silent,\n        };\n\n        let exit_status = package_manager.run_dlx_command(&options, &self.cwd).await?;\n\n        Ok(exit_status.code().unwrap_or(1))\n    }\n}\n```\n\n## Design Decisions\n\n### 1. Fallback to npx for Yarn 1.x\n\n**Decision**: When using yarn@1, fall back to `npx` instead of failing.\n\n**Rationale**:\n\n- Yarn Classic doesn't have a `dlx` command\n- `npx` comes bundled with npm and is almost always available\n- Provides a working solution rather than an error\n- Users are informed via a note that fallback is being used\n\n### 2. Package Flag Position\n\n**Decision**: Accept `--package` flags anywhere before the package spec.\n\n**Rationale**:\n\n- pnpm requires `--package` before `dlx`\n- npm allows `--package` anywhere\n- yarn requires `-p` after `dlx`\n- Our unified interface accepts it anywhere and maps accordingly\n\n### 3. Shell Mode Warning for Yarn\n\n**Decision**: Warn but proceed when shell mode is used with yarn.\n\n**Rationale**:\n\n- Yarn 2+ doesn't support shell mode\n- Better to warn and try than to fail entirely\n- Users can see the warning and adjust if needed\n- Some commands might work without shell mode\n\n### 4. Silent Mode Mapping\n\n**Decision**: Map `--silent` to equivalent flags for each PM.\n\n**Rationale**:\n\n- pnpm uses `--silent`\n- npm uses `--loglevel silent`\n- yarn uses `--quiet`\n- Provides consistent UX across package managers\n\n### 5. Command Extraction from Package Spec\n\n**Decision**: Automatically extract command name from package spec for npm.\n\n**Rationale**:\n\n- `npm exec` requires explicit command name after `--`\n- `pnpm dlx` and `yarn dlx` infer command from package\n- Automation provides consistent UX\n- Handles scoped packages correctly\n\n### 6. Fallback to npx Without package.json\n\n**Decision**: When no `package.json` is found anywhere up the directory tree, fall back to `npx` directly instead of erroring.\n\n**Rationale**:\n\n- `npx` doesn't require a `package.json` — `vp dlx` shouldn't either\n- Users may run `vp dlx` or `vpx` from directories outside any project (e.g., `/tmp`, home directory)\n- Without a `package.json`, there is no package manager to detect, so `npx` is the universal fallback\n- `prepend_js_runtime_to_path_env()` already handles the no-package.json case (uses CLI runtime), so `npx` is on PATH\n\n### 7. Auto-confirm Prompts for npm/npx\n\n**Decision**: Always add `--yes` flag for npm and npx (yarn@1 fallback).\n\n**Rationale**:\n\n- pnpm doesn't require confirmation prompts by default\n- yarn dlx doesn't require confirmation prompts\n- npm and npx prompt for confirmation when running packages not in cache\n- Auto-adding `--yes` ensures consistent behavior across all package managers\n- Removes npm-specific `--yes/-y` and `--no/-n` options from the CLI\n- Users expect `vp dlx` to behave the same regardless of underlying package manager\n\n## Error Handling\n\n### Missing Package Spec\n\n```bash\n$ vp dlx\nError: dlx requires a package name\n\nUsage: vp dlx [OPTIONS] <package[@version]> [args...]\n\nExamples:\n  vp dlx create-vue my-app\n  vp dlx typescript tsc --version\n```\n\n### No package.json\n\n```bash\n$ cd /tmp\n$ vp dlx cowsay hello\n# No package.json found — falls back to npx directly\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n```\n\n### Package Not Found\n\n```bash\n$ vp dlx non-existent-package-xyz\nDetected package manager: pnpm@10.15.0\nRunning: pnpm dlx non-existent-package-xyz\n ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND  No package.json was found for \"non-existent-package-xyz\"\nExit code: 1\n```\n\n### Network Error\n\n```bash\n$ vp dlx create-vue my-app\nDetected package manager: npm@11.0.0\nRunning: npm exec create-vue -- my-app\nnpm error code ENOTFOUND\nnpm error network request to https://registry.npmjs.org/create-vue failed\nExit code: 1\n```\n\n## User Experience\n\n### Basic Execution\n\n```bash\n$ vp dlx create-vue my-app\nDetected package manager: pnpm@10.15.0\nRunning: pnpm dlx create-vue my-app\n\nVue.js - The Progressive JavaScript Framework\n\n✔ Project name: my-app\n✔ Add TypeScript? Yes\n...\n```\n\n### Version Specific\n\n```bash\n$ vp dlx typescript@5.5.4 tsc --version\nDetected package manager: pnpm@10.15.0\nRunning: pnpm dlx typescript@5.5.4 tsc --version\nVersion 5.5.4\n```\n\n### Multiple Packages\n\n```bash\n$ vp dlx --package yo --package generator-webapp yo webapp\nDetected package manager: npm@11.0.0\nRunning: npm exec --package=yo --package=generator-webapp -- yo webapp\n? What would you like to do? Create a new webapp\n...\n```\n\n### Shell Mode\n\n```bash\n$ vp dlx --package cowsay --package lolcatjs -c 'echo \"Hello Vite+\" | cowsay | lolcatjs'\nDetected package manager: pnpm@10.15.0\nRunning: pnpm --package cowsay --package lolcatjs dlx -c 'echo \"Hello Vite+\" | cowsay | lolcatjs'\n _______________\n< Hello Vite+  >\n ---------------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n```\n\n### Yarn 1.x Fallback\n\n```bash\n$ vp dlx create-vue my-app\nDetected package manager: yarn@1.22.19\nNote: yarn@1 does not have dlx command, falling back to npx\nRunning: npx create-vue my-app\n...\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Always Use npx\n\n```bash\n# Simply wrap npx for all package managers\nvp dlx → npx\n```\n\n**Rejected because**:\n\n- Loses integration with pnpm's store and caching\n- Doesn't respect yarn 2+ project settings\n- Inconsistent with other vite commands that use detected PM\n- npx may not be available (though rare)\n\n### Alternative 2: Top-Level Aliases\n\n```bash\nvp create-vue my-app    # Implicit dlx\n```\n\n**Rejected because**:\n\n- Conflicts with potential future commands\n- Less explicit about what's happening\n- Harder to discover and document\n- Deviates from pnpm/npm/yarn conventions\n\nNote: A short alias `x` was initially considered but rejected for the same reasons - it's not explicit about what's happening and could conflict with future commands.\n\n### Alternative 3: No Fallback for Yarn 1.x\n\n```bash\n$ vp dlx create-vue\nError: yarn@1.22.19 does not support dlx command\n```\n\n**Rejected because**:\n\n- Frustrating user experience\n- npx fallback works well and is available\n- Other tools (like bunx) also provide fallbacks\n- Users shouldn't need to switch package managers for dlx\n\n## Implementation Plan\n\n### Phase 1: Core Infrastructure\n\n1. Add `Dlx` variant to `Commands` enum in `vite_command`\n2. Create `DlxCommandOptions` struct\n3. Implement `resolve_dlx_command` for each package manager\n4. Add `run_dlx_command` execution method\n\n### Phase 2: Package Manager Support\n\n1. Implement pnpm dlx resolution\n2. Implement npm exec resolution\n3. Implement yarn dlx resolution (v2+)\n4. Implement npx fallback for yarn v1\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Test package spec parsing\n3. Test option mapping for each PM\n4. Integration tests with mock package managers\n5. Test yarn v1 fallback behavior\n\n### Phase 4: Documentation\n\n1. Update CLI help text\n2. Add usage examples\n3. Document package manager compatibility\n4. Add troubleshooting guide\n\n## Testing Strategy\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_dlx_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = DlxCommandOptions {\n        packages: &[],\n        package_spec: \"create-vue\",\n        args: &[\"my-app\".into()],\n        shell_mode: false,\n        silent: false,\n    };\n    let result = pm.resolve_dlx_command(&options);\n    assert_eq!(result.bin_path, \"pnpm\");\n    assert_eq!(result.args, vec![\"dlx\", \"create-vue\", \"my-app\"]);\n}\n\n#[test]\nfn test_pnpm_dlx_with_packages() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = DlxCommandOptions {\n        packages: &[\"yo\".into(), \"generator-webapp\".into()],\n        package_spec: \"yo\",\n        args: &[\"webapp\".into()],\n        shell_mode: false,\n        silent: false,\n    };\n    let result = pm.resolve_dlx_command(&options);\n    assert_eq!(\n        result.args,\n        vec![\"--package\", \"yo\", \"--package\", \"generator-webapp\", \"dlx\", \"yo\", \"webapp\"]\n    );\n}\n\n#[test]\nfn test_npm_exec_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Npm, \"11.0.0\");\n    let options = DlxCommandOptions {\n        packages: &[],\n        package_spec: \"create-vue\",\n        args: &[\"my-app\".into()],\n        shell_mode: false,\n        silent: false,\n    };\n    let result = pm.resolve_dlx_command(&options);\n    assert_eq!(result.bin_path, \"npm\");\n    // --yes is always added to auto-confirm prompts\n    assert_eq!(result.args, vec![\"exec\", \"--yes\", \"--\", \"create-vue\", \"my-app\"]);\n}\n\n#[test]\nfn test_yarn_v1_fallback_to_npx() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn, \"1.22.19\");\n    let options = DlxCommandOptions {\n        packages: &[],\n        package_spec: \"create-vue\",\n        args: &[\"my-app\".into()],\n        shell_mode: false,\n        silent: false,\n    };\n    let result = pm.resolve_dlx_command(&options);\n    assert_eq!(result.bin_path, \"npx\");\n    // --yes is always added to auto-confirm prompts\n    assert_eq!(result.args, vec![\"--yes\", \"create-vue\", \"my-app\"]);\n}\n\n#[test]\nfn test_yarn_v2_dlx() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn, \"4.0.0\");\n    let options = DlxCommandOptions {\n        packages: &[],\n        package_spec: \"create-vue\",\n        args: &[\"my-app\".into()],\n        shell_mode: false,\n        silent: false,\n    };\n    let result = pm.resolve_dlx_command(&options);\n    assert_eq!(result.bin_path, \"yarn\");\n    assert_eq!(result.args, vec![\"dlx\", \"create-vue\", \"my-app\"]);\n}\n\n#[test]\nfn test_extract_command_from_spec() {\n    assert_eq!(extract_command_from_spec(\"create-vue\"), \"create-vue\");\n    assert_eq!(extract_command_from_spec(\"create-vue@3.10.0\"), \"create-vue\");\n    assert_eq!(extract_command_from_spec(\"@vue/cli\"), \"cli\");\n    assert_eq!(extract_command_from_spec(\"@vue/cli@5.0.0\"), \"cli\");\n}\n\n#[test]\nfn test_shell_mode() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = DlxCommandOptions {\n        packages: &[\"cowsay\".into()],\n        package_spec: \"echo hello | cowsay\",\n        args: &[],\n        shell_mode: true,\n        silent: false,\n    };\n    let result = pm.resolve_dlx_command(&options);\n    assert!(result.args.contains(&\"-c\".to_string()));\n}\n```\n\n## CLI Help Output\n\n```bash\n$ vp dlx --help\nExecute a package binary without installing it as a dependency\n\nUsage: vp dlx [OPTIONS] <package[@version]> [args...]\n\nArguments:\n  <package[@version]>  Package to execute (with optional version)\n  [args...]            Arguments to pass to the executed command\n\nOptions:\n  -p, --package <NAME>  Package(s) to install before running (can be used multiple times)\n  -c, --shell-mode      Execute the command within a shell environment\n  -s, --silent          Suppress all output except the executed command's output\n  -h, --help            Print help\n\nExamples:\n  vp dlx create-vue my-app                              # Create a new Vue project\n  vp dlx typescript@5.5.4 tsc --version                 # Run specific version\n  vp dlx -p yo -p generator-webapp yo webapp            # Multiple packages\n  vp dlx -c 'echo \"hello\" | cowsay'                     # Shell mode\n  vp dlx -s create-vue my-app                           # Silent mode\n```\n\n## Package Manager Compatibility\n\n| Feature           | pnpm    | npm     | yarn@1  | yarn@2+ | Notes                    |\n| ----------------- | ------- | ------- | ------- | ------- | ------------------------ |\n| Basic execution   | ✅ Full | ✅ Full | ⚠️ npx  | ✅ Full | yarn@1 uses npx fallback |\n| Version specifier | ✅ Full | ✅ Full | ⚠️ npx  | ✅ Full |                          |\n| --package flag    | ✅ Full | ✅ Full | ⚠️ npx  | ✅ Full |                          |\n| Shell mode (-c)   | ✅ Full | ✅ Full | ⚠️ npx  | ❌ N/A  | yarn@2+ doesn't support  |\n| Silent mode       | ✅ Full | ✅ Full | ⚠️ npx  | ✅ Full |                          |\n| Auto-confirm      | ✅ N/A  | ✅ Auto | ⚠️ Auto | ✅ N/A  | --yes added for npm/npx  |\n\n## Security Considerations\n\n1. **Remote Code Execution**: `dlx` inherently executes remote code. Users should:\n   - Verify package names before execution\n   - Use version specifiers for reproducibility\n   - Review package contents when uncertain\n\n2. **No Permanent Installation**: Packages are installed to a temporary cache, not project dependencies.\n   - Reduces supply chain attack surface\n   - No changes to package.json or lockfiles\n\n3. **Shell Mode Risks**: Shell mode (`-c`) allows arbitrary shell commands.\n   - Use with caution in scripts\n   - Avoid interpolating untrusted input\n\n4. **Build Scripts**: pnpm's `--allow-build` controls postinstall scripts.\n   - By default, dlx packages can run build scripts\n   - Consider security implications for untrusted packages\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New command is purely additive\n- No changes to configuration format\n- No changes to caching behavior\n\n## Future Enhancements\n\n### 1. Cache Management\n\n```bash\nvp dlx --clear-cache                # Clear dlx cache\nvp dlx --cache-dir                  # Show cache location\n```\n\n### 2. Offline Mode\n\n```bash\nvp dlx --offline create-vue my-app  # Use cached version only\n```\n\n### 3. Registry Override\n\n```bash\nvp dlx --registry https://custom.registry.com create-vue my-app\n```\n\n### 4. Trust Configuration\n\n```bash\n# In vite-task.json\n{\n  \"dlx\": {\n    \"trustedPackages\": [\"create-vue\", \"typescript\"],\n    \"allowBuild\": false\n  }\n}\n```\n\n### 5. Execution History\n\n```bash\nvp dlx --history                    # Show recent dlx executions\nvp dlx --replay 3                   # Re-run 3rd most recent command\n```\n\n## Real-World Usage Examples\n\n### Project Scaffolding\n\n```bash\n# Create new projects with various frameworks\nvp dlx create-vue my-vue-app\nvp dlx create-react-app my-react-app\nvp dlx create-next-app my-next-app\nvp dlx create-svelte my-svelte-app\nvp dlx @angular/cli ng new my-angular-app\n```\n\n### One-off Tools\n\n```bash\n# Format JSON\nvp dlx prettier --write package.json\n\n# Check TypeScript\nvp dlx typescript tsc --noEmit\n\n# Run ESLint\nvp dlx eslint src/\n\n# Generate licenses\nvp dlx license-checker --json\n```\n\n### CI/CD Pipelines\n\n```yaml\n# GitHub Actions\n- name: Create release notes\n  run: vp dlx -s conventional-changelog-cli -p angular > CHANGELOG.md\n\n- name: Check for vulnerabilities\n  run: vp dlx snyk test\n\n- name: Publish to npm\n  run: vp dlx np --no-tests\n```\n\n### Development Utilities\n\n```bash\n# Quick HTTP server\nvp dlx serve dist/\n\n# JSON server for mocking\nvp dlx json-server db.json\n\n# Bundle analyzer\nvp dlx source-map-explorer dist/*.js\n\n# Dependency visualization\nvp dlx madge --image deps.svg src/\n```\n\n## Conclusion\n\nThis RFC proposes adding `vp dlx` command to provide unified remote package execution across pnpm/npm/yarn. The design:\n\n- ✅ Unified interface for all package managers\n- ✅ Intelligent fallback for yarn@1\n- ✅ Pass-through for advanced options\n- ✅ Shell mode for complex commands\n- ✅ Silent mode for CI/scripting\n- ✅ Version specifiers for reproducibility\n- ✅ Multiple package support\n- ✅ Follows existing pnpm dlx conventions\n- ✅ Simple implementation leveraging existing infrastructure\n\nThe command provides the convenience of `npx`/`pnpm dlx`/`yarn dlx` with automatic package manager detection, ensuring consistent developer experience regardless of the project's package manager choice.\n"
  },
  {
    "path": "rfcs/env-command.md",
    "content": "# RFC: `vp env` - Shim-Based Node Version Management\n\n## Summary\n\nThis RFC proposes adding a `vp env` command that provides system-wide, IDE-safe Node.js version management through a shim-based architecture. The shims intercept `node`, `npm`, and `npx` commands, automatically resolving and executing the correct Node.js version based on project configuration.\n\n> **Note**: Corepack shim is not included as vite-plus has integrated package manager functionality.\n\n## Motivation\n\n### Current Pain Points\n\n1. **IDE Integration Issues**: GUI-launched IDEs (VS Code, Cursor) often don't see shell-configured Node versions because they inherit PATH from the system environment, not shell rc files.\n\n2. **Version Manager Fragmentation**: Users must choose between nvm, fnm, volta, asdf, or mise - each with different setup requirements and shell integrations.\n\n3. **Inconsistent Behavior**: Terminal-launched vs GUI-launched applications may use different Node versions, causing subtle bugs.\n\n4. **Manual Version Switching**: Users must remember to run `nvm use` or similar when entering projects.\n\n### Proposed Solution\n\nA shim-based approach where:\n\n- `VITE_PLUS_HOME/bin/` directory is added to PATH (system-level for IDE reliability)\n- Shims (`node`, `npm`, `npx`) are symlinks to the `vp` binary (Unix) or trampoline `.exe` files (Windows)\n- The `vp` CLI itself is also in `VITE_PLUS_HOME/bin/`, so users only need one PATH entry\n- The binary detects invocation via `argv[0]` and dispatches accordingly\n- Version resolution and installation leverage existing `vite_js_runtime` infrastructure\n\n## Command Usage\n\n### Setup Commands\n\n```bash\n# Initial setup - creates shims and shows PATH configuration instructions\nvp env setup\n\n# Force refresh shims (after vp binary upgrade)\nvp env setup --refresh\n\n# Set the global default Node.js version (used when no project version file exists)\nvp env default 20.18.0\nvp env default lts        # Use latest LTS version\nvp env default latest     # Use latest version (not recommended for stability)\n\n# Show current default version\nvp env default\n\n# Control shim mode\nvp env on             # Enable managed mode (shims always use vite-plus Node.js)\nvp env off            # Enable system-first mode (shims prefer system Node.js)\n```\n\n### Diagnostic Commands\n\n```bash\n# Comprehensive system diagnostics\nvp env doctor\n\n# Show which node binary would be executed in current directory\nvp env which node\nvp env which npm\n\n# Output current environment info as JSON\nvp env --current --json\n# Output: {\"version\":\"20.18.0\",\"source\":\".node-version\",\"project_root\":\"/path/to/project\",\"node_path\":\"/path/to/node\"}\n\n# Print shell snippet for current session (fallback for special environments)\nvp env --print\n```\n\n### Version Management Commands\n\n```bash\n# Pin a specific version in current directory (creates .node-version)\nvp env pin 20.18.0\n\n# Pin using version aliases (resolved to exact version)\nvp env pin lts        # Resolves and pins current LTS (e.g., 22.13.0)\nvp env pin latest     # Resolves and pins latest version\n\n# Pin using semver ranges\nvp env pin \"^20.0.0\"\n\n# Show current pinned version\nvp env pin\n\n# Remove pin (delete .node-version file)\nvp env pin --unpin\nvp env unpin          # Alternative syntax\n\n# Skip pre-downloading the pinned version\nvp env pin 20.18.0 --no-install\n\n# List locally installed Node.js versions\nvp env list\nvp env ls             # Alias\n\n# List available Node.js versions from the registry\nvp env list-remote\nvp env list-remote --lts     # Show only LTS versions\nvp env list-remote 20        # Show versions matching pattern\n```\n\n### Session Version Override\n\n```bash\n# Use a specific Node.js version for this shell session\nvp env use 24          # Switch to Node 24.x\nvp env use lts         # Switch to latest LTS\nvp env use             # Install & activate project's configured version\nvp env use --unset     # Remove session override\n\n# Options\nvp env use --no-install           # Skip auto-install if version not present\nvp env use --silent-if-unchanged  # Suppress output if version already active\n```\n\n**How it works:**\n\n1. `~/.vite-plus/env` includes a `vp()` shell function that intercepts `vp env use` calls\n2. The wrapper sets `VITE_PLUS_ENV_USE_EVAL_ENABLE=1` before calling `command vp env use ...`\n3. When the env var is present (wrapper active), `vp env use` outputs shell commands to stdout for eval\n4. When the env var is absent (CI, direct invocation), `vp env use` writes a session file (`~/.vite-plus/.session-node-version`) instead\n5. The shim dispatch checks `VITE_PLUS_NODE_VERSION` env var first, then the session file, in the resolution chain\n\n**Automatic session file (for CI / wrapper-less environments):**\n\nWhen `vp env use` detects that the shell eval wrapper is not active (i.e., `VITE_PLUS_ENV_USE_EVAL_ENABLE` is not set), it automatically writes the resolved version to `~/.vite-plus/.session-node-version`. Shims read this file directly from disk, so `vp env use` works without the shell wrapper — no extra flags needed. The env var still takes priority when set, so the shell wrapper experience is unchanged.\n\n```bash\n# GitHub Actions example (no shell wrapper, session file written automatically)\n- run: vp env use 20\n- run: node --version   # v20.x via shim reading session file\n- run: vp env use --unset  # Clean up\n```\n\n**Shell-specific output:**\n\n| Shell            | Set                                       | Unset                                        |\n| ---------------- | ----------------------------------------- | -------------------------------------------- |\n| POSIX (bash/zsh) | `export VITE_PLUS_NODE_VERSION=20.18.1`   | `unset VITE_PLUS_NODE_VERSION`               |\n| Fish             | `set -gx VITE_PLUS_NODE_VERSION 20.18.1`  | `set -e VITE_PLUS_NODE_VERSION`              |\n| PowerShell       | `$env:VITE_PLUS_NODE_VERSION = \"20.18.1\"` | `Remove-Item Env:VITE_PLUS_NODE_VERSION ...` |\n| cmd.exe          | `set VITE_PLUS_NODE_VERSION=20.18.1`      | `set VITE_PLUS_NODE_VERSION=`                |\n\n**Shell function wrappers** are included in env files created by `vp env setup`:\n\n- `~/.vite-plus/env` (POSIX - bash/zsh): `vp()` function\n- `~/.vite-plus/env.fish` (fish): `function vp`\n- `~/.vite-plus/env.ps1` (PowerShell): `function vp`\n- `~/.vite-plus/bin/vp-use.cmd` (cmd.exe): dedicated wrapper since cmd.exe lacks shell functions\n\n### Node.js Version Management\n\n```bash\n# Install a Node.js version\nvp env install 20.18.0\nvp env install lts\nvp env install latest\n\n# Uninstall a Node.js version\nvp env uninstall 20.18.0\n```\n\n### Global Package Commands\n\n```bash\n# Install a global package\nvp install -g typescript\nvp install -g typescript@5.0.0\n\n# Install with specific Node.js version\nvp install -g --node 22 typescript\nvp install -g --node lts typescript\n\n# Force install (auto-uninstalls conflicting packages)\nvp install -g --force eslint-v9    # Removes 'eslint' if it provides same binary\n\n# List installed global packages\nvp list -g\nvp list -g --json\n\n# Example output (table format with colored package names):\n# Package            Node version   Binaries\n# ---                ---            ---\n# pnpm@10.28.2      22.22.0        pnpm, pnpx\n# serve@14.2.5      22.22.0        serve\n# typescript@5.9.3  22.22.0        tsc, tsserver\n\n# Uninstall a global package\nvp remove -g typescript\n\n# Update global packages\nvp update -g              # Update all global packages\nvp update -g typescript   # Update specific package\n```\n\n### Daily Usage (After Setup)\n\n```bash\n# These commands are intercepted by shims automatically\nnode -v           # Uses project-specific version\nnpm install       # Uses correct npm for the resolved Node version\nnpx vitest        # Uses correct npx\n```\n\n## Architecture Overview\n\n### Single-Binary Multi-Role Design\n\nThe `vp` binary serves dual purposes based on `argv[0]`:\n\n```\nargv[0] = \"vp\"        → Normal CLI mode (vp env, vp build, etc.)\nargv[0] = \"node\"      → Shim mode: resolve version, exec node\nargv[0] = \"npm\"       → Shim mode: resolve version, exec npm\nargv[0] = \"npx\"       → Shim mode: resolve version, exec npx\n```\n\n### Architecture Diagram\n\n```\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                           PATH CONFIGURATION                                │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│  User's PATH (after setup):                                                 │\n│                                                                             │\n│    PATH=\"~/.vite-plus/bin:/usr/local/bin:/usr/bin:...\"                      │\n│           ▲                                                                 │\n│           │                                                                 │\n│           └── First in PATH = shims intercept node/npm/npx commands         │\n│                                                                             │\n│  When user runs `node`:                                                     │\n│                                                                             │\n│    $ node app.js                                                            │\n│        │                                                                    │\n│        ▼                                                                    │\n│    Shell searches PATH left-to-right:                                       │\n│        1. ~/.vite-plus/bin/node  ✓ Found! (shim)                            │\n│        2. /usr/local/bin/node    (skipped)                                  │\n│        3. /usr/bin/node          (skipped)                                  │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                           SHIM DISPATCH FLOW                                │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│  User runs:  $ node app.js                                                  │\n│                  │                                                          │\n│                  ▼                                                          │\n│  ┌──────────────────────────────┐                                           │\n│  │  ~/.vite-plus/bin/node       │  ◄── Symlink to vp binary (via PATH)      │\n│  │  (shim intercepts command)   │                                           │\n│  └──────────────┬───────────────┘                                           │\n│                 │                                                           │\n│                 ▼                                                           │\n│  ┌──────────────────────────────┐                                           │\n│  │  argv[0] Detection           │                                           │\n│  │  \"node\" → shim mode          │                                           │\n│  └──────────────┬───────────────┘                                           │\n│                 │                                                           │\n│                 ▼                                                           │\n│  ┌──────────────────────────────┐     ┌─────────────────────────────┐       │\n│  │  Version Resolution          │────▶│  Priority Order:            │       │\n│  │  (walk up directory tree)    │     │  0. VITE_PLUS_NODE_VERSION  │       │\n│  └──────────────┬───────────────┘     │  1. .session-node-version   │       │\n│                 │                     │  2. .node-version           │       │\n│                 │                     │  3. package.json#engines    │       │\n│                 │                     │  4. package.json#devEngines │       │\n│                 │                     │  5. User default (config)   │       │\n│                 │                     │  6. Latest LTS              │       │\n│                 ▼                     └─────────────────────────────┘       │\n│  ┌──────────────────────────────┐                                           │\n│  │  Ensure Node.js installed    │                                           │\n│  │  (download if needed)        │                                           │\n│  └──────────────┬───────────────┘                                           │\n│                 │                                                           │\n│                 ▼                                                           │\n│  ┌──────────────────────────────┐                                           │\n│  │  execve() real node binary   │                                           │\n│  │  ~/.vite-plus/.../node       │                                           │\n│  └──────────────────────────────┘                                           │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                         DIRECTORY STRUCTURE                                 │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│  ~/.vite-plus/                        (VITE_PLUS_HOME)                      │\n│  ├── bin/                                                                   │\n│  │   ├── vp   ──────────────────────  Symlink to ../current/bin/vp          │\n│  │   ├── node ──────────────────────┐                                       │\n│  │   ├── npm  ──────────────────────┼──▶ Symlinks to ../current/bin/vp      │\n│  │   └── npx  ──────────────────────┘                                       │\n│  ├── current/bin/vp                   The actual vp CLI binary              │\n│  ├── js_runtime/node/                 Node.js installations                 │\n│  │   ├── 20.18.0/bin/node             Installed Node.js versions            │\n│  │   ├── 22.13.0/bin/node                                                   │\n│  │   └── ...                                                                │\n│  ├── .session-node-version              Session override (written by vp env use)│\n│  └── config.json                      User settings (default version, etc.) │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                      VERSION RESOLUTION (walk_up=true)                      │\n├─────────────────────────────────────────────────────────────────────────────┤\n│                                                                             │\n│  /home/user/projects/app/src/         ◄── Current directory                 │\n│           │                                                                 │\n│           ▼                                                                 │\n│  ┌─────────────────────────────────────────────────────────────────┐        │\n│  │ Check /home/user/projects/app/src/                              │        │\n│  │   ├── .node-version?     ✗ not found                            │        │\n│  │   └── package.json?      ✗ not found                            │        │\n│  └─────────────────────────────────────────────────────────────────┘        │\n│           │ walk up                                                         │\n│           ▼                                                                 │\n│  ┌─────────────────────────────────────────────────────────────────┐        │\n│  │ Check /home/user/projects/app/                                  │        │\n│  │   ├── .node-version?     ✗ not found                            │        │\n│  │   └── package.json?      ✓ found! engines.node = \"^20.0.0\"      │        │\n│  └─────────────────────────────────────────────────────────────────┘        │\n│           │                                                                 │\n│           ▼                                                                 │\n│  Return: version=\"^20.0.0\", source=\"engines.node\",                          │\n│          project_root=\"/home/user/projects/app\"                             │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n```\n\n### VITE_PLUS_HOME Directory Layout\n\n```\nVITE_PLUS_HOME/                              # Default: ~/.vite-plus\n├── bin/\n│   ├── vp -> ../current/bin/vp       # Symlink to current vp binary (Unix)\n│   ├── node -> ../current/bin/vp     # Symlink to vp binary (Unix)\n│   ├── npm -> ../current/bin/vp      # Symlink to vp binary (Unix)\n│   ├── npx -> ../current/bin/vp      # Symlink to vp binary (Unix)\n│   ├── tsc -> ../current/bin/vp      # Symlink for global package (Unix)\n│   ├── vp.exe                        # Trampoline forwarding to current\\bin\\vp.exe (Windows)\n│   ├── node.exe                      # Trampoline shim for node (Windows)\n│   ├── npm.exe                       # Trampoline shim for npm (Windows)\n│   ├── npx.exe                       # Trampoline shim for npx (Windows)\n│   └── tsc.exe                       # Trampoline shim for global package (Windows)\n├── current/\n│   └── bin/\n│       ├── vp                        # The actual vp CLI binary (Unix)\n│       └── vp.exe                    # The actual vp CLI binary (Windows)\n├── js_runtime/\n│   └── node/\n│       ├── 20.18.0/                  # Installed Node versions\n│       │   └── bin/\n│       │       ├── node\n│       │       ├── npm\n│       │       └── npx\n│       └── 22.13.0/\n├── packages/                         # Global packages\n│   ├── typescript/\n│   │   └── lib/\n│   │       └── node_modules/\n│   │           └── typescript/\n│   │               └── bin/\n│   ├── typescript.json               # Package metadata\n│   ├── eslint/\n│   └── eslint.json\n├── bins/                             # Per-binary config files (tracks ownership)\n│   ├── tsc.json                      # { \"package\": \"typescript\", ... }\n│   ├── tsserver.json\n│   └── eslint.json\n├── shared/                           # NODE_PATH symlinks\n│   ├── typescript -> ../packages/typescript/lib/node_modules/typescript\n│   └── eslint -> ../packages/eslint/lib/node_modules/eslint\n├── cache/\n│   └── resolve_cache.json            # LRU cache for version resolution\n├── tmp/                              # Staging directory for installs\n│   └── packages/\n├── .session-node-version             # Session override (written by `vp env use`)\n└── config.json                       # User configuration (default version, etc.)\n```\n\n**Key Directories:**\n\n| Directory          | Purpose                                                            |\n| ------------------ | ------------------------------------------------------------------ |\n| `bin/`             | vp symlink and all shims (node, npm, npx, global package binaries) |\n| `current/bin/`     | The actual vp CLI binary (bin/ shims point here)                   |\n| `js_runtime/node/` | Installed Node.js versions                                         |\n| `packages/`        | Installed global packages with metadata                            |\n| `bins/`            | Per-binary config files (tracks which package owns each binary)    |\n| `shared/`          | NODE_PATH symlinks for package require() resolution                |\n| `tmp/`             | Staging area for atomic installations                              |\n| `cache/`           | Resolution cache                                                   |\n\n### config.json Format\n\n```json\n// ~/.vite-plus/config.json\n\n{\n  // Default Node.js version when no project version file is found\n  // Set via: vp env default <version>\n  \"defaultNodeVersion\": \"20.18.0\",\n\n  // Alternatively, use aliases:\n  // \"defaultNodeVersion\": \"lts\"     // Always use latest LTS\n  // \"defaultNodeVersion\": \"latest\"  // Always use latest (not recommended)\n\n  // Shim mode: controls how shims resolve tools\n  // Set via: vp env on (managed) or vp env off (system_first)\n  // - \"managed\" (default): Shims always use vite-plus managed Node.js\n  // - \"system_first\": Shims prefer system Node.js, fallback to managed if not found\n  \"shimMode\": \"managed\"\n}\n```\n\n## Version Specification\n\nThis section documents the supported version formats for `.node-version` files, `package.json` engines, and CLI commands.\n\n### Supported Version Formats\n\nvite-plus supports the following version specification formats, compatible with nvm, fnm, and actions/setup-node:\n\n| Format              | Example                           | Resolution                     | Cache Expiry        |\n| ------------------- | --------------------------------- | ------------------------------ | ------------------- |\n| **Exact version**   | `20.18.0`, `v20.18.0`             | Used directly                  | mtime-based         |\n| **Partial version** | `20`, `20.18`                     | Highest matching (prefers LTS) | time-based (1 hour) |\n| **Semver range**    | `^20.0.0`, `~20.18.0`, `>=20 <22` | Highest matching (prefers LTS) | time-based (1 hour) |\n| **LTS latest**      | `lts/*`                           | Highest LTS version            | time-based (1 hour) |\n| **LTS codename**    | `lts/iron`, `lts/jod`             | Highest version in LTS line    | time-based (1 hour) |\n| **LTS offset**      | `lts/-1`, `lts/-2`                | nth-highest LTS line           | time-based (1 hour) |\n| **Wildcard**        | `*`                               | Latest version                 | time-based (1 hour) |\n\n### Exact Versions\n\nExact three-part versions are used directly without network resolution:\n\n```\n20.18.0      → 20.18.0\nv20.18.0     → 20.18.0 (v prefix stripped)\n22.13.1      → 22.13.1\n```\n\n### Partial Versions\n\nPartial versions (major or major.minor) are resolved to the highest matching version at runtime. LTS versions are preferred over non-LTS versions:\n\n```\n20           → 20.19.0 (highest 20.x LTS)\n20.18        → 20.18.3 (highest 20.18.x)\n22           → 22.13.0 (highest 22.x LTS)\n```\n\n### Semver Ranges\n\nStandard npm/node-semver range syntax is supported. LTS versions are preferred within the matching range:\n\n```\n^20.0.0      → 20.19.0 (highest 20.x.x LTS)\n~20.18.0     → 20.18.3 (highest 20.18.x)\n>=20 <22     → 20.19.0 (highest in range, LTS preferred)\n18 || 20     → 20.19.0 (highest LTS in either range)\n18.x         → 18.20.5 (highest 18.x)\n```\n\n### LTS Aliases\n\nLTS (Long Term Support) versions can be specified using special aliases, following the pattern established by nvm and actions/setup-node:\n\n**`lts/*`** - Resolves to the latest (highest version number) LTS version:\n\n```\nlts/*        → 22.13.0 (latest LTS as of 2025)\n```\n\n**`lts/<codename>`** - Resolves to the highest version in a specific LTS line:\n\n```\nlts/iron     → 20.19.0 (highest v20.x)\nlts/jod      → 22.13.0 (highest v22.x)\nlts/hydrogen → 18.20.5 (highest v18.x)\nlts/krypton  → 24.x.x (when available)\n```\n\nCodenames are case-insensitive (`lts/Iron` and `lts/iron` both work).\n\n**`lts/-n`** - Resolves to the nth-highest LTS line (useful for testing against older supported versions):\n\n```\nlts/-1       → 20.19.0 (second-highest LTS, when latest is 22.x)\nlts/-2       → 18.20.5 (third-highest LTS)\n```\n\n### LTS Codename Reference\n\n| Codename | Major Version | LTS Status                   |\n| -------- | ------------- | ---------------------------- |\n| Hydrogen | 18.x          | Maintenance until 2025-04-30 |\n| Iron     | 20.x          | Active LTS until 2026-04-30  |\n| Jod      | 22.x          | Active LTS until 2027-04-30  |\n| Krypton  | 24.x          | Will be LTS starting 2025-10 |\n\nNew LTS codenames are added dynamically based on the Node.js release schedule. vite-plus fetches the version index from nodejs.org to resolve codenames, ensuring new LTS versions are supported automatically.\n\n### Version Resolution Priority\n\nWhen resolving which Node.js version to use, vite-plus checks the following sources in order:\n\n0. **`VITE_PLUS_NODE_VERSION` env var** (session override, highest priority)\n   - Set by `vp env use` via shell wrapper eval\n   - Overrides all file-based resolution\n\n1. **`.session-node-version`** file (session override)\n   - Written by `vp env use` to `~/.vite-plus/.session-node-version`\n   - Works without shell eval wrapper (CI environments)\n   - Deleted by `vp env use --unset`\n\n2. **`.node-version`** file\n   - Checked in current directory, then parent directories\n   - Simple format: one version per file\n\n3. **`package.json#engines.node`**\n   - Checked in current directory, then parent directories\n   - Standard npm constraint field\n\n4. **`package.json#devEngines.runtime`**\n   - Checked in current directory, then parent directories\n   - npm RFC-compliant development engines spec\n\n5. **User default** (`~/.vite-plus/config.json`)\n   - Set via `vp env default <version>`\n\n6. **System default** (latest LTS)\n   - Fallback when no version source is found\n\n### Cache Behavior\n\nVersion resolution results are cached for performance:\n\n- **Exact versions**: Cached until the source file mtime changes\n- **Range versions** (partial, semver, LTS aliases): Cached with 1-hour TTL, then re-resolved to pick up new releases\n\nThis ensures that:\n\n- Exact version pins are fast and deterministic\n- Range specifications can pick up new releases (e.g., `20` will use a newly released `20.20.0`)\n- LTS aliases automatically use newer patch versions\n\n### File Format Compatibility\n\nThe `.node-version` file format is intentionally simple and compatible with other tools:\n\n```\n# Supported content (one per file):\n20.18.0\nv20.18.0\n20\nlts/*\nlts/iron\n^20.0.0\n\n# Comments are NOT supported\n# Leading/trailing whitespace is trimmed\n# Only the first line is used\n```\n\n**Compatibility matrix:**\n\n| Tool               | `.node-version` | `.nvmrc` | LTS aliases | Semver ranges |\n| ------------------ | --------------- | -------- | ----------- | ------------- |\n| vite-plus          | ✅              | ✅       | ✅          | ✅            |\n| nvm                | ❌              | ✅       | ✅          | ✅            |\n| fnm                | ✅              | ✅       | ✅          | ✅            |\n| volta              | ✅              | ❌       | ❌          | ❌            |\n| actions/setup-node | ✅              | ✅       | ✅          | ✅            |\n| asdf               | ✅              | ❌       | ❌          | ❌            |\n\n**Note**: Node.js binaries are stored in VITE_PLUS_HOME:\n\n- Linux/macOS: `~/.vite-plus/js_runtime/node/{version}/`\n- Windows: `%USERPROFILE%\\.vite-plus\\js_runtime\\node\\{version}\\`\n\n## Implementation Architecture\n\n### File Structure\n\n```\ncrates/vite_global_cli/\n├── src/\n│   ├── main.rs                       # Entry point with shim detection\n│   ├── cli.rs                        # Add Env command\n│   ├── shim/\n│   │   ├── mod.rs                    # Shim module root\n│   │   ├── dispatch.rs               # Main shim dispatch logic\n│   │   ├── exec.rs                   # Platform-specific execution\n│   │   └── cache.rs                  # Resolution cache\n│   └── commands/\n│       └── env/\n│           ├── mod.rs                # Env command module\n│           ├── config.rs             # Configuration and version resolution\n│           ├── setup.rs              # setup subcommand implementation\n│           ├── doctor.rs             # doctor subcommand implementation\n│           ├── which.rs              # which subcommand implementation\n│           ├── current.rs            # --current implementation\n│           ├── default.rs            # default subcommand implementation\n│           ├── on.rs                 # on subcommand implementation\n│           ├── off.rs                # off subcommand implementation\n│           ├── pin.rs                # pin subcommand implementation\n│           ├── unpin.rs              # unpin subcommand implementation\n│           ├── list.rs               # list subcommand implementation\n│           └── use.rs                # use subcommand implementation\n```\n\n### Shim Dispatch Flow\n\n1. Check `VITE_PLUS_BYPASS` environment variable → bypass to system tool (filters all listed directories from PATH)\n2. Check `VITE_PLUS_TOOL_RECURSION` → if set, use passthrough mode\n3. Check shim mode from config:\n   - If `system_first`: try system tool first, fallback to managed; appends own bin dir to `VITE_PLUS_BYPASS` before exec to prevent loops with multiple installations\n   - If `managed`: use vite-plus managed Node.js\n4. Resolve version (with mtime-based caching)\n5. Ensure Node.js is installed (download if needed)\n6. Locate tool binary in the installed Node.js\n7. Prepend real node bin dir to PATH for child processes\n8. Set `VITE_PLUS_TOOL_RECURSION=1` to prevent recursion\n9. Execute the tool (Unix: `execve`, Windows: spawn)\n\n### Shim Recursion Prevention\n\nTo prevent infinite loops when shims invoke other shims, vite-plus uses environment variable markers:\n\n**Environment Variable**: `VITE_PLUS_TOOL_RECURSION`\n\n**Mechanism:**\n\n1. When a shim executes the real binary, it sets `VITE_PLUS_TOOL_RECURSION=1`\n2. Subsequent shim invocations check this variable\n3. If set, shims use **passthrough mode** (skip version resolution, use current PATH)\n4. `vp env exec` explicitly **removes** this variable to force re-evaluation\n\n**Environment Variable**: `VITE_PLUS_BYPASS` (PATH-style list)\n\n**SystemFirst Loop Prevention:**\n\nWhen multiple vite-plus installations exist in PATH and `system_first` mode is active, each installation could find the other's shim as the \"system tool\", causing an infinite exec loop. To prevent this:\n\n1. In `system_first` mode, before exec'ing the found system tool, the current installation appends its own bin directory to `VITE_PLUS_BYPASS`\n2. The next installation sees `VITE_PLUS_BYPASS` is set and enters bypass mode via `find_system_tool()`\n3. `find_system_tool()` filters all directories listed in `VITE_PLUS_BYPASS` (plus its own bin dir) from PATH\n4. This ensures the search skips all known vite-plus bin directories and finds the real system binary (or errors cleanly)\n5. `VITE_PLUS_BYPASS` is preserved through `vp env exec` so loop protection remains active\n\n**Flow Diagram:**\n\n```\nUser runs: node app.js\n    │\n    ▼\nShim checks VITE_PLUS_TOOL_RECURSION\n    │\n    ├── Not set → Resolve version, set RECURSION=1, exec real node\n    │\n    └── Set → Passthrough mode (use current PATH)\n```\n\n**Code Example:**\n\n```rust\nconst RECURSION_ENV_VAR: &str = \"VITE_PLUS_TOOL_RECURSION\";\n\nfn execute_shim() {\n    if env::var(RECURSION_ENV_VAR).is_ok() {\n        // Passthrough: context already evaluated\n        execute_with_current_path();\n    } else {\n        // First invocation: resolve version and set marker\n        let version = resolve_version();\n        let path = build_path_for_version(version);\n\n        env::set_var(RECURSION_ENV_VAR, \"1\");\n        execute_with_path(path);\n    }\n}\n\nfn execute_run_command() {\n    // Clear marker to force re-evaluation\n    env::remove_var(RECURSION_ENV_VAR);\n\n    let version = parse_version_from_args();\n    execute_with_version(version);\n}\n```\n\n**Why This Matters:**\n\n- Prevents infinite loops when Node scripts spawn other Node processes\n- Allows `vp env exec` to override versions mid-execution\n- Ensures consistent behavior in complex process trees\n\n## Design Decisions\n\n### 1. Single Binary with argv[0] Detection\n\n**Decision**: Use a single `vp` binary that detects shim mode from `argv[0]`.\n\n**Rationale**:\n\n- Simplifies upgrades (update one binary, refresh shims)\n- Reduces disk usage vs separate binaries\n- Consistent behavior across all tools\n- Already proven pattern (used by fnm, volta)\n\n### 2. Symlinks for Shims (Unix)\n\n**Decision**: Use symlinks for all shims on Unix, pointing to the vp binary.\n\n**Rationale**:\n\n- Symlinks preserve argv[0] - executing a symlink sets argv[0] to the symlink path, not the target\n- Proven pattern used by Volta successfully\n- Single binary to maintain - update `current/bin/vp` and all shims work\n- No binary accumulation issues (symlinks are just filesystem pointers)\n- Relative symlinks (e.g., `../current/bin/vp`) work within the same directory tree\n\n### 3. Trampoline Executables for Windows\n\n**Decision**: Use lightweight trampoline `.exe` files on Windows instead of `.cmd` wrappers. Each trampoline detects its tool name from its own filename, sets `VITE_PLUS_SHIM_TOOL`, and spawns `vp.exe`. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md).\n\n**Rationale**:\n\n- `.cmd` wrappers cause \"Terminate batch job (Y/N)?\" prompt on Ctrl+C\n- `.exe` files work in all shells (cmd.exe, PowerShell, Git Bash) without needing separate wrappers\n- Single trampoline binary (~100-150KB) copied per tool — no `.cmd` + shell script pair needed\n- Ctrl+C handled cleanly via `SetConsoleCtrlHandler`\n\n### 4. execve on Unix, spawn on Windows\n\n**Decision**: Use `execve` (process replacement) on Unix, `spawn` on Windows.\n\n**Rationale**:\n\n- `execve` preserves PID, signals, and process hierarchy on Unix\n- Windows doesn't support `execve`-style process replacement\n- `spawn` on Windows with proper exit code propagation is standard practice\n\n### 5. Separate VITE_PLUS_HOME from Cache\n\n**Decision**: Keep VITE_PLUS_HOME (bin, config) separate from cache (Node binaries).\n\n**Rationale**:\n\n- Cache uses XDG/platform-standard locations (already implemented)\n- VITE_PLUS_HOME needs to be user-accessible for PATH configuration\n- Allows clearing cache without breaking shim setup\n\n### 6. mtime-Based Cache Invalidation\n\n**Decision**: Invalidate resolution cache when version file mtime changes.\n\n**Rationale**:\n\n- Fast O(1) validation (stat call)\n- No need to re-parse files on every invocation\n- Content changes trigger mtime updates\n- Simple and reliable\n\n## Error Handling\n\n### No Version File Found (Default Fallback)\n\nWhen no version file is found, vite-plus uses the configured default version:\n\n```bash\n$ node -v\nv20.18.0  # Uses user-configured default (set via 'vp env default 20.18.0')\n\n# If no default configured, uses latest LTS\n$ node -v\nv22.13.0  # Falls back to latest LTS\n```\n\nThe resolution order is:\n\n1. `VITE_PLUS_NODE_VERSION` env var (session override)\n2. `.session-node-version` file (session override)\n3. `.node-version` in current or parent directories\n4. `package.json#engines.node` in current or parent directories\n5. `package.json#devEngines.runtime` in current or parent directories\n6. **User Default**: Configured via `vp env default <version>` (stored in `~/.vite-plus/config.json`)\n7. **System Default**: Latest LTS version\n\n### Installation Failure\n\n```bash\n$ node -v\nvp: Failed to install Node 20.18.0: Network error: connection refused\nvp: Check your network connection and try again\nvp: Or set VITE_PLUS_BYPASS=1 to use system node\n```\n\n### Tool Not Found\n\n```bash\n$ npx vitest\nvp: Tool 'npx' not found in Node 14.0.0 installation\nvp: npx is available in Node 5.2.0+\n```\n\n### PATH Misconfiguration\n\n```bash\n$ vp env doctor\nInstallation\n  ✓ VITE_PLUS_HOME    ~/.vite-plus\n  ✓ Bin directory     exists\n  ✓ Shims             node, npm, npx\n\nConfiguration\n  ✓ Shim mode         managed\n\nPATH\n  ✗ vp                not in PATH\n                      Expected: ~/.vite-plus/bin\n\n    Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):\n\n      . \"$HOME/.vite-plus/env\"\n\n    Then restart your terminal.\n\n...\n\n✗ Some issues found. Run the suggested commands to fix them.\n```\n\n## User Experience\n\n### First-Time Setup via Install Script\n\n**Note on Directory Structure:**\n\n- All binaries (vp CLI and shims): `~/.vite-plus/bin/`\n\nThe global CLI installation script (`packages/global/install.sh`) will be updated to:\n\n1. Install the `vp` binary to `~/.vite-plus/current/bin/vp`\n2. Create symlink `~/.vite-plus/bin/vp` → `../current/bin/vp`\n3. Configure shell PATH to include `~/.vite-plus/bin`\n4. Setup Node.js version manager based on environment:\n   - **CI environment**: Auto-enable (no prompt)\n   - **No system Node.js**: Auto-enable (no prompt)\n   - **Interactive with system Node.js**: Prompt user \"Would you want Vite+ to manage Node.js versions?\"\n5. If already configured, skip silently\n\n```bash\n$ curl -fsSL https://vite.plus | sh\n\nSetting up VITE+...\n\nWould you want Vite+ to manage Node.js versions?\nPress Enter to accept (Y/n):\n\n✔ VITE+ successfully installed!\n\n  The Unified Toolchain for the Web.\n\n  Get started:\n    vp create       Create a new project\n    vp env          Manage Node.js versions\n    vp install      Install dependencies\n    vp dev          Start dev server\n\n  Node.js is now managed by Vite+ (via vp env).\n  Run vp env doctor to verify your setup.\n\n  Run vp help for more information.\n\n  Note: Run `source ~/.zshrc` or restart your terminal.\n```\n\n### Manual Setup\n\nIf user declines or needs to reconfigure:\n\n```bash\n$ vp env setup\n\nSetting up vite-plus environment...\n\nCreated shims:\n  /Users/user/.vite-plus/bin/node\n  /Users/user/.vite-plus/bin/npm\n  /Users/user/.vite-plus/bin/npx\n\nAdd to your shell profile (~/.zshrc, ~/.bashrc, etc.):\n\n  export PATH=\"/Users/user/.vite-plus/bin:$PATH\"\n\nFor IDE support (VS Code, Cursor), ensure bin directory is in system PATH:\n  - macOS: Add to ~/.profile or use launchd\n  - Linux: Add to ~/.profile for display manager integration\n  - Windows: System Properties → Environment Variables → Path\n\nRestart your terminal and IDE, then run 'vp env doctor' to verify.\n```\n\n### Doctor Output (Healthy)\n\n```bash\n$ vp env doctor\nInstallation\n  ✓ VITE_PLUS_HOME    ~/.vite-plus\n  ✓ Bin directory     exists\n  ✓ Shims             node, npm, npx\n\nConfiguration\n  ✓ Shim mode         managed\n  ✓ IDE integration   env sourced in ~/.zshenv\n\nPATH\n  ✓ vp                first in PATH\n  ✓ node              ~/.vite-plus/bin/node (vp shim)\n  ✓ npm               ~/.vite-plus/bin/npm (vp shim)\n  ✓ npx               ~/.vite-plus/bin/npx (vp shim)\n\nVersion Resolution\n    Directory         /Users/user/projects/my-app\n    Source            .node-version\n    Version           20.18.0\n  ✓ Node binary       installed\n\n✓ All checks passed\n```\n\n**Doctor Output with Session Override:**\n\n```bash\n$ vp env doctor\n...\n\nConfiguration\n  ✓ Shim mode         managed\n  ✓ IDE integration   env sourced in ~/.zshenv\n  ⚠ Session override  VITE_PLUS_NODE_VERSION=20.18.0\n                      Overrides all file-based resolution.\n                      Run 'vp env use --unset' to remove.\n  ⚠ Session override (file)  .session-node-version=20.18.0\n                      Written by 'vp env use'. Run 'vp env use --unset' to remove.\n\n...\n```\n\n**Doctor Output with System-First Mode:**\n\n```bash\n$ vp env doctor\n...\n\nConfiguration\n  ✓ Shim mode         system-first\n    System Node.js    /usr/local/bin/node\n  ✓ IDE integration   env sourced in ~/.zshenv\n\n...\n```\n\n**Doctor Output with System-First Mode (No System Node):**\n\n```bash\n$ vp env doctor\n...\n\nConfiguration\n  ✓ Shim mode         system-first\n  ⚠ System Node.js    not found (will use managed)\n\n...\n```\n\n**Doctor Output (Unhealthy):**\n\n```bash\n$ vp env doctor\nInstallation\n  ✓ VITE_PLUS_HOME    ~/.vite-plus\n  ✗ Bin directory     does not exist\n  ✗ Missing shims     node, npm, npx\n                      Run 'vp env setup' to create bin directory and shims.\n\nConfiguration\n  ✓ Shim mode         managed\n\nPATH\n  ✗ vp                not in PATH\n                      Expected: ~/.vite-plus/bin\n\n    Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):\n\n      . \"$HOME/.vite-plus/env\"\n\n    For fish shell, add to ~/.config/fish/config.fish:\n\n      source \"$HOME/.vite-plus/env.fish\"\n\n    Then restart your terminal.\n\n  node                not found\n  npm                 not found\n  npx                 not found\n\nVersion Resolution\n    Directory         /Users/user/projects/my-app\n    Source            .node-version\n    Version           20.18.0\n  ⚠ Node binary       not installed\n                      Version will be downloaded on first use.\n\nConflicts\n  ⚠ nvm               detected (NVM_DIR is set)\n                      Consider removing other version managers from your PATH\n                      to avoid version conflicts.\n\nIDE Setup\n  ⚠ GUI applications may not see shell PATH changes.\n\n    macOS:\n      Add to ~/.zshenv or ~/.profile:\n        . \"$HOME/.vite-plus/env\"\n      Then restart your IDE to apply changes.\n\n✗ Some issues found. Run the suggested commands to fix them.\n```\n\n## Shell Configuration Reference\n\nThis section documents shell configuration file behavior for PATH setup and troubleshooting.\n\n### Zsh Configuration Files\n\n| File        | When Loaded                                                              | Use Case                           |\n| ----------- | ------------------------------------------------------------------------ | ---------------------------------- |\n| `.zshenv`   | **Always** - every zsh instance (login, interactive, scripts, subshells) | PATH and environment variables     |\n| `.zprofile` | Login shells only                                                        | Login-time initialization          |\n| `.zshrc`    | Interactive shells only                                                  | Aliases, functions, prompts        |\n| `.zlogin`   | Login shells, after `.zshrc`                                             | Commands after full initialization |\n\n**Loading Order (Login Interactive Shell):**\n\n```\n1. /etc/zshenv     → System environment\n2. ~/.zshenv       → User environment (ALWAYS loaded)\n3. /etc/zprofile   → System login setup\n4. ~/.zprofile     → User login setup\n5. /etc/zshrc      → System interactive setup\n6. ~/.zshrc        → User interactive setup\n7. /etc/zlogin     → System login finalization\n8. ~/.zlogin       → User login finalization\n```\n\n**Key Point:** `.zshenv` is the **most reliable** location for PATH configuration because:\n\n- Loaded for ALL zsh instances including IDE-spawned processes\n- Loaded even for non-interactive scripts and subshells\n\n### Bash Configuration Files\n\n| File            | When Loaded                  | Use Case                                        |\n| --------------- | ---------------------------- | ----------------------------------------------- |\n| `.bash_profile` | Login shells only            | macOS Terminal, SSH sessions                    |\n| `.bash_login`   | Login shells only (fallback) | Used if `.bash_profile` absent                  |\n| `.profile`      | Login shells only (fallback) | Used if neither above exists; also read by `sh` |\n| `.bashrc`       | Interactive non-login shells | Linux terminal emulators, subshells             |\n\n**Loading Order (Login Shell):**\n\n```\n1. /etc/profile           → System profile\n2. FIRST found of:        → User profile (ONLY ONE is loaded)\n   - ~/.bash_profile\n   - ~/.bash_login\n   - ~/.profile\n3. ~/.bashrc              → ONLY if explicitly sourced by above\n```\n\n**Critical Behavior:**\n\n- Bash reads **only the first** profile file found (`.bash_profile` > `.bash_login` > `.profile`)\n- `.bashrc` is **NOT automatically loaded** in login shells - the profile file must source it\n- Standard pattern: `.bash_profile` should contain `source ~/.bashrc`\n\n### Fish Configuration Files\n\nFish shell uses a simpler configuration model than bash/zsh.\n\n| File                              | When Loaded                                                    | Use Case                         |\n| --------------------------------- | -------------------------------------------------------------- | -------------------------------- |\n| `~/.config/fish/config.fish`      | **Always** - every fish instance (login, interactive, scripts) | All configuration including PATH |\n| `~/.config/fish/conf.d/*.fish`    | **Always** - before config.fish                                | Modular configuration snippets   |\n| `~/.config/fish/functions/*.fish` | On-demand when function called                                 | Autoloaded function definitions  |\n\n**Key Points:**\n\n- Fish has **no distinction** between login and non-login shells for configuration\n- `config.fish` is always loaded, similar to zsh's `.zshenv`\n- This makes Fish more reliable for IDE integration than bash\n- Universal variables (`set -U`) persist across sessions without config files\n\n**PATH Syntax:**\n\n```fish\n# Fish uses different syntax than bash/zsh\nset -gx PATH $HOME/.vite-plus/bin $PATH\n```\n\n### When Configuration Files May NOT Load\n\n| Scenario                 | Zsh Behavior    | Bash Behavior                       | Fish Behavior        |\n| ------------------------ | --------------- | ----------------------------------- | -------------------- |\n| Non-interactive scripts  | Only `.zshenv`  | **NOTHING** (unless `BASH_ENV` set) | `config.fish` loaded |\n| IDE-launched processes   | Only `.zshenv`  | **NOTHING** (critical gap)          | `config.fish` loaded |\n| SSH sessions             | All login files | `.bash_profile` only                | `config.fish` loaded |\n| Subshells                | Only `.zshenv`  | `.bashrc` (interactive) or nothing  | `config.fish` loaded |\n| macOS Terminal.app       | All login files | `.bash_profile` → `.bashrc`         | `config.fish` loaded |\n| Linux terminal emulators | `.zshrc`        | `.bashrc` only                      | `config.fish` loaded |\n\n### IDE Integration Challenges\n\nGUI-launched IDEs (VS Code, Cursor, JetBrains) have special PATH inheritance issues:\n\n**macOS:**\n\n- GUI apps inherit environment from `launchd`, not shell rc files\n- IDE terminals may spawn login or non-login shells (varies by IDE settings)\n- Solution: `.zshenv` for zsh; for bash, both `.bash_profile` and `.bashrc` needed\n\n**Linux:**\n\n- GUI apps inherit from display manager session\n- `~/.profile` is often sourced by display managers (GDM, SDDM, etc.)\n- Non-login terminals only read `.bashrc`\n\n**Windows:**\n\n- PATH is system/user environment variable\n- No shell rc file complications\n\n### Install Script Shell Configuration\n\nThe `install.sh` script configures PATH in multiple shell files for maximum compatibility:\n\n**For Zsh (`$SHELL` ends with `/zsh`):**\n\n- Adds to `~/.zshenv` - ensures all zsh instances see the PATH\n- Adds to `~/.zshrc` - ensures PATH is at front for interactive shells\n\n**For Bash (`$SHELL` ends with `/bash`):**\n\n- Adds to `~/.bash_profile` - for login shells (macOS default)\n- Adds to `~/.bashrc` - for interactive non-login shells (Linux default)\n- Adds to `~/.profile` - fallback for systems without `.bash_profile`\n\n**For Fish (`$SHELL` ends with `/fish`):**\n\n- Adds to `~/.config/fish/config.fish`\n\n**Important Notes:**\n\n1. Only modifies files that **already exist** - does not create new rc files\n2. Checks for existing PATH entry to avoid duplicates\n3. Appends with comment marker: `# Vite+ bin (https://viteplus.dev)`\n\n### Troubleshooting PATH Issues\n\n**Symptom: `vp` not found after installation**\n\n1. Check which shell you're using:\n\n   ```bash\n   echo $SHELL\n   ```\n\n2. Verify the PATH entry was added:\n\n   ```bash\n   # For zsh\n   grep \"vite-plus\" ~/.zshenv ~/.zshrc\n\n   # For bash\n   grep \"vite-plus\" ~/.bash_profile ~/.bashrc ~/.profile\n\n   # For fish\n   grep \"vite-plus\" ~/.config/fish/config.fish\n   ```\n\n3. If no entry found, manually add to appropriate file:\n\n   ```bash\n   # For zsh/bash - add this line:\n   export PATH=\"$HOME/.vite-plus/bin:$PATH\"\n\n   # For fish - add this line:\n   set -gx PATH $HOME/.vite-plus/bin $PATH\n   ```\n\n4. Source the file or restart terminal:\n   ```bash\n   source ~/.zshrc  # or ~/.bashrc\n   # For fish: source ~/.config/fish/config.fish\n   ```\n\n**Symptom: IDE terminal doesn't see `vp` or `node`**\n\n1. For VS Code, check terminal profile settings (login shell recommended)\n2. Ensure `~/.zshenv` contains the PATH entry (most reliable for zsh)\n3. For bash users: may need to configure IDE to use login shell (`bash -l`)\n4. Fish users: `config.fish` is always loaded, so PATH should work in IDEs\n5. Run `vp env doctor` to diagnose PATH configuration\n\n**Symptom: Shell scripts can't find `node`**\n\nFor bash scripts, non-interactive execution doesn't load rc files. Options:\n\n- Use `#!/usr/bin/env bash` with `BASH_ENV` set\n- Source the rc file explicitly: `source ~/.bashrc`\n- Use full path: `~/.vite-plus/bin/node`\n\nNote: Fish scripts (`#!/usr/bin/env fish`) always load `config.fish`, so this issue doesn't apply.\n\n### Default Version Command\n\n```bash\n# Show current default version\n$ vp env default\nDefault Node.js version: 20.18.0\n  Set via: ~/.vite-plus/config.json\n\n# Set a specific version as default\n$ vp env default 22.13.0\n✓ Default Node.js version set to 22.13.0\n\n# Set to latest LTS\n$ vp env default lts\n✓ Default Node.js version set to lts (currently 22.13.0)\n\n# When no default is configured\n$ vp env default\nNo default version configured. Using latest LTS (22.13.0).\n  Run 'vp env default <version>' to set a default.\n```\n\n### Shim Mode Commands\n\nThe shim mode controls how shims resolve tools:\n\n| Mode                | Description                                                   |\n| ------------------- | ------------------------------------------------------------- |\n| `managed` (default) | Shims always use vite-plus managed Node.js                    |\n| `system_first`      | Shims prefer system Node.js, fallback to managed if not found |\n\n```bash\n# Enable managed mode (always use vite-plus Node.js)\n$ vp env on\n✓ Shim mode set to managed.\n\nShims will now always use the Vite+ managed Node.js.\nRun 'vp env off' to prefer system Node.js instead.\n\n# Enable system-first mode (prefer system Node.js)\n$ vp env off\n✓ Shim mode set to system-first.\n\nShims will now prefer system Node.js, falling back to managed if not found.\nRun 'vp env on' to always use vite-plus managed Node.js.\n\n# If already in the requested mode\n$ vp env on\nShim mode is already set to managed.\nShims will always use vite-plus managed Node.js.\n```\n\n**Use cases for system-first mode (`vp env off`)**:\n\n- When you have a system Node.js that you want to use by default\n- When working on projects that don't need vite-plus version management\n- When debugging version-related issues by comparing system vs managed Node.js\n\n### Which Command\n\nShows the path to the tool binary that would be executed. The first line is always the bare path (pipe-friendly, copy-pastable).\n\n**Core tools** - shows the resolved Node.js binary path with version and resolution source:\n\n```bash\n$ vp env which node\n/Users/user/.vite-plus/js_runtime/node/20.18.0/bin/node\n  Version:    20.18.0\n  Source:     /Users/user/projects/my-app/.node-version\n\n$ vp env which npm\n/Users/user/.vite-plus/js_runtime/node/20.18.0/bin/npm\n  Version:    20.18.0\n  Source:     /Users/user/projects/my-app/.node-version\n```\n\nWhen using session override:\n\n```bash\n$ vp env which node\n/Users/user/.vite-plus/js_runtime/node/18.20.0/bin/node\n  Version:    18.20.0\n  Source:     VITE_PLUS_NODE_VERSION (session)\n```\n\n**Global packages** - shows binary path plus package metadata:\n\n```bash\n$ vp env which tsc\n/Users/user/.vite-plus/packages/typescript/lib/node_modules/typescript/bin/tsc\n  Package:    typescript@5.7.0\n  Binaries:   tsc, tsserver\n  Node:       20.18.0\n  Installed:  2024-01-15\n\n$ vp env which eslint\n/Users/user/.vite-plus/packages/eslint/lib/node_modules/eslint/bin/eslint.js\n  Package:    eslint@9.0.0\n  Binaries:   eslint\n  Node:       22.13.0\n  Installed:  2024-02-20\n```\n\n| Tool Type       | Resolution                          | Output                                                         |\n| --------------- | ----------------------------------- | -------------------------------------------------------------- |\n| Core tools      | Node.js version from project config | Binary path + Version + Source                                 |\n| Global packages | Package metadata lookup             | Binary path + Package version + Node.js version + Install date |\n\n**Error cases:**\n\n```bash\n# Unknown tool (not core tool, not in any global package)\n$ vp env which unknown-tool\nerror: tool 'unknown-tool' not found\nNot a core tool (node, npm, npx) or installed global package.\nRun 'vp list -g' to see installed packages.\n\n# Node.js version not installed\n$ vp env which node\nerror: node not found\nNode.js 20.18.0 is not installed.\nRun 'vp env install 20.18.0' to install it.\n\n# Global package binary missing\n$ vp env which tsc\nerror: binary 'tsc' not found\nPackage typescript may need to be reinstalled.\nRun 'vp install -g typescript' to reinstall.\n```\n\n## Pin Command\n\nThe `vp env pin` command provides per-directory Node.js version pinning by managing `.node-version` files.\n\n### Behavior\n\n**Pinning a Version:**\n\n```bash\n$ vp env pin 20.18.0\n✓ Pinned Node.js version to 20.18.0\n  Created .node-version in /Users/user/projects/my-app\n✓ Node.js 20.18.0 installed\n```\n\n**Pinning with Aliases:**\n\nAliases (`lts`, `latest`) are resolved to exact versions at pin time for reproducibility:\n\n```bash\n$ vp env pin lts\n✓ Pinned Node.js version to 22.13.0 (resolved from lts)\n  Created .node-version in /Users/user/projects/my-app\n✓ Node.js 22.13.0 installed\n```\n\n**Showing Current Pin:**\n\n```bash\n$ vp env pin\nPinned version: 20.18.0\n  Source: /Users/user/projects/my-app/.node-version\n\n# If no .node-version in current directory but found in parent\n$ vp env pin\nNo version pinned in current directory.\n  Inherited: 22.13.0 from /Users/user/projects/.node-version\n\n# If no .node-version anywhere\n$ vp env pin\nNo version pinned.\n  Using default: 20.18.0 (from ~/.vite-plus/config.json)\n```\n\n**Removing a Pin:**\n\n```bash\n$ vp env pin --unpin\n✓ Removed .node-version from /Users/user/projects/my-app\n\n# Alternative syntax\n$ vp env unpin\n✓ Removed .node-version from /Users/user/projects/my-app\n```\n\n### Version Format Support\n\n| Input     | Written to File | Behavior                         |\n| --------- | --------------- | -------------------------------- |\n| `20.18.0` | `20.18.0`       | Exact version                    |\n| `20.18`   | `20.18`         | Latest 20.18.x at runtime        |\n| `20`      | `20`            | Latest 20.x.x at runtime         |\n| `lts`     | `22.13.0`       | Resolved at pin time             |\n| `latest`  | `24.0.0`        | Resolved at pin time             |\n| `^20.0.0` | `^20.0.0`       | Semver range resolved at runtime |\n\n### Flags\n\n| Flag           | Description                                             |\n| -------------- | ------------------------------------------------------- |\n| `--unpin`      | Remove the `.node-version` file                         |\n| `--no-install` | Skip pre-downloading the pinned version                 |\n| `--force`      | Overwrite existing `.node-version` without confirmation |\n\n### Pre-download Behavior\n\nBy default, `vp env pin` downloads the Node.js version immediately after pinning. Use `--no-install` to skip:\n\n```bash\n$ vp env pin 20.18.0 --no-install\n✓ Pinned Node.js version to 20.18.0\n  Created .node-version in /Users/user/projects/my-app\n  Note: Version will be downloaded on first use.\n```\n\n### Overwrite Confirmation\n\nWhen a `.node-version` file already exists:\n\n```bash\n$ vp env pin 22.13.0\n.node-version already exists with version 20.18.0\nOverwrite with 22.13.0? (y/n): y\n✓ Pinned Node.js version to 22.13.0\n```\n\nUse `--force` to skip confirmation:\n\n```bash\n$ vp env pin 22.13.0 --force\n✓ Pinned Node.js version to 22.13.0\n```\n\n### Error Handling\n\n```bash\n# Invalid version format\n$ vp env pin invalid\nError: Invalid Node.js version: invalid\n  Use exact version (20.18.0), partial version (20), or semver range (^20.0.0)\n\n# Version doesn't exist\n$ vp env pin 99.0.0\nError: Node.js version 99.0.0 does not exist\n  Run 'vp env list-remote' to see available versions\n\n# Network error during alias resolution\n$ vp env pin lts\nError: Failed to resolve 'lts': Network error\n  Check your network connection and try again\n```\n\n## Global Package Management\n\nvite-plus provides cross-Node-version global package management via `vp install -g`, `vp remove -g`, and `vp update -g`. Unlike `npm install -g` which installs into a Node-version-specific directory, vite-plus manages global packages independently so they persist across Node.js version changes.\n\nNote: `npm install -g` passes through to the real npm (Node-version-specific). Use `vp install -g` for vite-plus managed global packages.\n\n### How It Works\n\nWhen you run `vp install -g typescript`, vite-plus:\n\n1. Resolves the Node.js version (from `--node` flag or current directory)\n2. Installs the package to `~/.vite-plus/packages/typescript/`\n3. Records metadata (package version, Node version used, binaries)\n4. Creates shims for each binary the package provides (`tsc`, `tsserver`)\n\n### Installation Flow\n\n```\nvp install -g typescript\n    │\n    ▼\nParse global flag → route to managed global install\n    │\n    ▼\nCreate staging: ~/.vite-plus/tmp/packages/typescript/\n    │\n    ▼\nSet npm_config_prefix → staging directory\n    │\n    ▼\nExecute npm with modified environment\n    │\n    ▼\nOn success:\n├── Move to: ~/.vite-plus/packages/typescript/\n├── Write config: ~/.vite-plus/packages/typescript.json\n├── Create shims: ~/.vite-plus/bin/tsc, tsserver\n└── Update shared NODE_PATH link\n```\n\n### Package Configuration File\n\n`~/.vite-plus/packages/typescript.json`:\n\n```json\n{\n  \"name\": \"typescript\",\n  \"version\": \"5.7.0\",\n  \"platform\": {\n    \"node\": \"20.18.0\",\n    \"npm\": \"10.8.0\"\n  },\n  \"bins\": [\"tsc\", \"tsserver\"],\n  \"manager\": \"npm\",\n  \"installedAt\": \"2024-01-15T10:30:00Z\"\n}\n```\n\n### Binary Execution\n\nWhen running `tsc`:\n\n1. Shim reads `~/.vite-plus/packages/typescript.json`\n2. Loads the pinned platform (Node 20.18.0)\n3. Constructs PATH with that Node version's bin directory\n4. Sets NODE_PATH to include shared packages\n5. Executes `~/.vite-plus/packages/typescript/lib/node_modules/.bin/tsc`\n\n### Installation with Specific Node.js Version\n\n```bash\n# Install a global package (uses Node.js version from current directory)\nvp install -g typescript\n\n# Install with a specific Node.js version\nvp install -g --node 22 typescript\nvp install -g --node 20.18.0 typescript\nvp install -g --node lts typescript\n\n# Install multiple packages\nvp install -g typescript eslint prettier\n```\n\nThe `--node` flag allows you to specify which Node.js version to use for installation. If not provided, it resolves the version from the current directory (same as shim behavior).\n\n### Upgrade and Uninstall\n\n```bash\n# Upgrade replaces the existing package\nvp update -g typescript\nvp install -g typescript@latest\n\n# Update all global packages\nvp update -g\n\n# Uninstall removes package and shims\nvp remove -g typescript\n```\n\n### Binary Conflict Handling\n\nWhen two packages provide the same binary name (e.g., both `eslint` and `eslint-v9` provide an `eslint` binary), vite-plus uses a **Volta-style hard fail** approach:\n\n#### Conflict Detection\n\nEach binary has a per-binary config file that tracks which package owns it:\n\n```\n~/.vite-plus/\n  packages/\n    typescript.json      # Package metadata\n    eslint.json\n  bins/                  # Per-binary config files\n    tsc.json             # { \"package\": \"typescript\", ... }\n    tsserver.json\n    eslint.json          # { \"package\": \"eslint\", ... }\n```\n\n**Binary config format** (`~/.vite-plus/bins/tsc.json`):\n\n```json\n{\n  \"name\": \"tsc\",\n  \"package\": \"typescript\",\n  \"version\": \"5.7.0\",\n  \"nodeVersion\": \"20.18.0\"\n}\n```\n\n#### Default Behavior: Hard Fail\n\nWhen installing a package that provides a binary already owned by another package, the installation **fails with a clear error**:\n\n```bash\n$ vp install -g eslint-v9\nInstalling eslint-v9 globally...\n\nerror: Executable 'eslint' is already installed by eslint\n\nPlease remove eslint before installing eslint-v9, or use --force to auto-replace\n```\n\nThis approach:\n\n- Prevents silent binary masking\n- Makes conflicts explicit and visible\n- Requires intentional user action to resolve\n\n#### Force Mode: Auto-Uninstall\n\nThe `--force` flag automatically uninstalls the conflicting package before installing the new one:\n\n```bash\n$ vp install -g --force eslint-v9\nInstalling eslint-v9 globally...\nUninstalling eslint (conflicts with eslint-v9)...\nUninstalled eslint\nInstalled eslint-v9 v9.0.0\nBinaries: eslint\n```\n\n**Important**: `--force` completely removes the conflicting package (not just the binary). This ensures a clean state without orphaned files.\n\n#### Two-Phase Uninstall\n\nUninstall uses a resilient two-phase approach (inspired by Volta):\n\n1. **Phase 1**: Try to use `PackageMetadata` to get binary names\n2. **Phase 2**: If metadata is missing, scan `bins/` directory for orphaned binary configs\n\nThis allows recovery even if package metadata is corrupted or manually deleted.\n\n```bash\n# Normal uninstall\n$ vp remove -g typescript\nUninstalling typescript...\nUninstalled typescript\n\n# Recovery mode (if typescript.json is missing)\n$ vp remove -g typescript\nUninstalling typescript...\nnote: Package metadata not found, scanning for orphaned binaries...\nUninstalled typescript\n```\n\n#### Deterministic Binary Resolution\n\nBinary execution uses per-binary config for deterministic lookup:\n\n1. Check `~/.vite-plus/bins/{binary}.json` for owner package\n2. Load package metadata to get Node.js version and binary path\n3. If not found, the binary is not installed (no fallback scanning)\n\nThis eliminates the non-deterministic behavior of filesystem iteration order.\n\n### npm Global Install Guidance\n\nWhen the npm shim detects `npm install -g <packages>`, it runs real npm normally but uses `spawn+wait` (instead of `exec`) so it can run post-install checks. After npm completes successfully, it checks whether the installed binaries are reachable from `$PATH` and prints a hint if they aren't.\n\n#### Why This Is Needed\n\n```\n~/.vite-plus/\n├── bin/                          ← ON $PATH (only this dir)\n│   ├── node → ../current/bin/vp  (shim)\n│   ├── npm → ../current/bin/vp   (shim)\n│   └── npx → ../current/bin/vp   (shim)\n└── js_runtime/node/20.18.0/bin/  ← NOT on $PATH\n    ├── node\n    ├── npm\n    ├── npx\n    └── codex                     ← installed by `npm i -g`, but unreachable\n```\n\nUsers instinctively run `npm install -g codex`, which installs into the managed Node's bin dir — not on `$PATH`. The binary is silently unreachable.\n\n#### Call Flow: `npm install -g codex` (with post-install hint)\n\n```\nUser runs: npm install -g codex\n         │\n         ▼\n┌─────────────────────────┐\n│  ~/.vite-plus/bin/npm   │  (symlink to vp binary)\n│  argv[0] = \"npm\"        │\n└────────────┬────────────┘\n             │\n             ▼\n┌───────────────────────────────────────────────────────────┐\n│  dispatch(\"npm\", [\"install\", \"-g\", \"codex\"])               │\n│  (crates/vite_global_cli/src/shim/dispatch.rs)             │\n│                                                             │\n│  1–5. vpx / recursion / bypass / shim / core checks        │\n│  6. resolve version    → 20.18.0                           │\n│  7. ensure installed   → ok                                │\n│  8. locate npm binary  → ~/.vite-plus/js_runtime/          │\n│                           node/20.18.0/bin/npm              │\n│  9. save original_path = $PATH                             │\n│  10. prepend node bin dir to PATH                          │\n│  11. set recursion marker                                  │\n│                                                             │\n│  ┌─── npm global install detection ─────────────────────┐  │\n│  │                                                       │  │\n│  │  parse_npm_global_install(args)                       │  │\n│  │    → detects \"install\" + \"-g\"                         │  │\n│  │    → extracts packages: [\"codex\"]                     │  │\n│  │    → returns Some(NpmGlobalInstall)                   │  │\n│  │                                                       │  │\n│  │  spawn_tool(npm_path, args)    ← NOT exec!            │  │\n│  │    → runs real npm install -g codex                   │  │\n│  │    → waits for completion, exit_code = 0              │  │\n│  │                                                       │  │\n│  │  check_npm_global_install_result(                     │  │\n│  │      pkgs, ver, orig_path, npm_path)                  │  │\n│  │                                                       │  │\n│  │    ┌─ Determine actual npm global prefix ───────────┐ │  │\n│  │    │  run `npm config get prefix` → e.g. /usr/local │ │  │\n│  │    │  npm_bin_dir = <prefix>/bin/                    │ │  │\n│  │    │  (fallback: node_dir if npm fails)             │ │  │\n│  │    └────────────────────────────────────────────────┘ │  │\n│  │                                                       │  │\n│  │    ┌─ Is npm_bin_dir in original_path? ─────────────┐ │  │\n│  │    │  YES → return (binaries on PATH)               │ │  │\n│  │    │  NO  → continue to per-binary check            │ │  │\n│  │    └────────────────────────────────────────────────┘ │  │\n│  │                                                       │  │\n│  │    → for each binary in package:                      │  │\n│  │        skip core shims (node/npm/npx/vp)              │  │\n│  │        if already exists in ~/.vite-plus/bin/:         │  │\n│  │          if BinConfig exists → managed_conflicts       │  │\n│  │          skip (don't overwrite)                        │  │\n│  │        check source exists in npm_bin_dir             │  │\n│  │        add to missing_bins list                       │  │\n│  │    → warn about managed conflicts                     │  │\n│  │    → interactive? prompt to create links              │  │\n│  │      non-interactive? create links directly           │  │\n│  │    → prints tip: use `vp install -g` instead          │  │\n│  │                                                       │  │\n│  │  return exit_code (0)                                 │  │\n│  └───────────────────────────────────────────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**Conflict with `vp install -g` shims**: If a binary already exists in `~/.vite-plus/bin/` AND has a BinConfig file (`~/.vite-plus/bins/{name}.json`), it is managed by `vp install -g`. The shim warns the user instead of silently skipping:\n\n```\n'codex' is already managed by `vp install -g`. Run `vp uninstall -g` first to replace it.\n```\n\n**Interactive mode** (stdin is a TTY):\n\n```\n'codex' is not available on your PATH.\nCreate a link in ~/.vite-plus/bin/ to make it available? [Y/n]\n```\n\nIf the user confirms (Y or Enter):\n\n- Creates a symlink: `~/.vite-plus/bin/codex` → `~/.vite-plus/js_runtime/node/20.18.0/bin/codex`\n- Prints: `Linked 'codex' to ~/.vite-plus/bin/codex`\n\nThen always prints the tip:\n\n```\ntip: Use `vp install -g codex` for managed shims that persist across Node.js version changes.\n```\n\n**Non-interactive mode** (piped/CI):\n\n- Creates the symlink directly (no prompt)\n- Prints: `Linked 'codex' to ~/.vite-plus/bin/codex`\n- Prints the same tip\n\n#### Call Flow: Normal `npm install react` — unaffected\n\n```\nUser runs: npm install react\n         │\n         ▼\n┌───────────────────────────────────────────────────┐\n│  dispatch(\"npm\", [\"install\", \"react\"])              │\n│                                                     │\n│  ... version resolution, PATH setup ...             │\n│                                                     │\n│  parse_npm_global_install(args)                      │\n│    → no \"-g\" or \"--global\" flag                      │\n│    → returns None                                    │\n│                                                     │\n│  (falls through to normal exec_tool)                 │\n│    → exec_tool(npm_path, args)                       │\n│       └─ replaces process with real npm (Unix exec)  │\n└───────────────────────────────────────────────────┘\n```\n\n#### `npm uninstall -g` Link Cleanup\n\nWhen `npm uninstall -g` is detected, the shim uses `spawn_tool()` (like install) to retain control after npm finishes. Before running npm, it collects bin names from the package's `package.json` (which will be removed by npm). After a successful uninstall, it removes the corresponding symlinks from `~/.vite-plus/bin/`.\n\n**Link tracking via BinConfig**: When `npm install -g` creates links in `~/.vite-plus/bin/`, a `BinConfig` with `source: \"npm\"` is written to `~/.vite-plus/bins/{name}.json`. This distinguishes npm-created links from `vp install -g` managed shims (`source: \"vp\"`) and user-owned binaries (no BinConfig).\n\n**Safe uninstall cleanup**: `npm uninstall -g` only removes links that have a BinConfig with `source: \"npm\"` AND whose `package` field matches the package being uninstalled. This prevents removing links that were overwritten by a later install of a different package exposing the same bin name. User-owned binaries and `vp install -g` managed shims are never touched.\n\n**`--prefix` support**: When `--prefix <dir>` is passed to `npm install -g` or `npm uninstall -g`, the shim uses that prefix for package.json lookups and bin dir resolution instead of running `npm config get prefix`. Both absolute and relative paths are supported — relative paths (e.g., `./custom`, `../foo`) are resolved against the current working directory.\n\n**Windows local path support**: `resolve_package_name()` treats drive-letter paths (`C:\\...`) as local paths.\n\n#### Design Decision: spawn vs exec\n\nOn Unix, `exec_tool()` uses `exec()` which replaces the current process — no code runs after. For `npm install -g` and `npm uninstall -g` specifically, we use `spawn_tool()` (spawn + wait) to retain control after npm finishes, enabling the post-install hint and post-uninstall link cleanup. All other npm commands continue to use `exec_tool()` for zero overhead.\n\n## Exec Command\n\nThe `vp env exec` command executes a command with a specific Node.js version. It operates in two modes:\n\n1. **Explicit version mode**: When `--node` is provided, runs with the specified version\n2. **Shim mode**: When `--node` is not provided and the command is a shim tool (node/npm/npx or global package), uses the same version resolution as Unix symlinks\n\nThis is useful for:\n\n- Testing code against different Node versions\n- Running one-off commands without changing project configuration\n- CI/CD scripts that need explicit version control\n- Legacy Windows `.cmd` wrappers (deprecated in favor of trampoline `.exe` shims)\n\n### Usage\n\n```bash\n# Shim mode: version resolved automatically (same as Unix symlinks)\nvp env exec node --version        # Core tool - resolves from .node-version/package.json\nvp env exec npm install           # Core tool\nvp env exec npx vitest            # Core tool\nvp env exec tsc --version         # Global package - uses Node.js from install time\n\n# Explicit version mode: run with specific Node version\nvp env exec --node 20.18.0 node app.js\n\n# Run with specific Node and npm versions\nvp env exec --node 22.13.0 --npm 10.8.0 npm install\n\n# Version can be semver range (resolved at runtime)\nvp env exec --node \"^20.0.0\" node -v\n\n# Run npm scripts\nvp env exec --node 18.20.0 npm test\n\n# Pass arguments to the command\nvp env exec --node 20 -- node --inspect app.js\n\n# Error: non-shim command without --node\nvp env exec python --version      # Fails: --node required for non-shim tools\n```\n\n### Flags\n\n| Flag               | Description                                                                   |\n| ------------------ | ----------------------------------------------------------------------------- |\n| `--node <version>` | Node.js version to use (optional for shim tools, required for other commands) |\n| `--npm <version>`  | npm version to use (not yet implemented, uses bundled npm)                    |\n\n### Shim Mode Behavior\n\nWhen `--node` is **not provided** and the first command is a shim tool:\n\n- **Core tools (node, npm, npx)**: Version resolved from `.node-version`, `package.json#engines.node`, or default\n- **Global packages (tsc, eslint, etc.)**: Uses the Node.js version that was used during `vp install -g`\n\nBoth use the **exact same code path** as Unix symlinks (`shim::dispatch()`), ensuring identical behavior across platforms. On Windows, trampoline `.exe` shims set `VITE_PLUS_SHIM_TOOL` to enter shim dispatch mode.\n\n**Important**: The `VITE_PLUS_TOOL_RECURSION` environment variable is cleared before dispatch to ensure fresh version resolution, even when invoked from within a context where the variable is already set (e.g., when pnpm runs through the vite-plus shim).\n\n### Explicit Version Mode Behavior\n\nWhen `--node` **is provided**:\n\n1. **Version Resolution**: Specified versions are resolved to exact versions\n2. **Auto-Install**: If the version isn't installed, it's downloaded automatically\n3. **PATH Construction**: Constructs PATH with specified version's bin directory\n4. **Recursion Reset**: Clears `VITE_PLUS_TOOL_RECURSION` to force context re-evaluation\n\n### Examples\n\n```bash\n# Shim mode: same behavior as Unix symlinks\nvp env exec node -v               # Uses version from project config\nvp env exec npm install           # Uses same version\nvp env exec tsc --version         # Global package\n\n# Test against multiple Node versions in CI\nfor version in 18 20 22; do\n  vp env exec --node $version npm test\ndone\n\n# Run with exact version\nvp env exec --node 20.18.0 node -e \"console.log(process.version)\"\n# Output: v20.18.0\n\n# Debug with specific Node version\nvp env exec --node 22 -- node --inspect-brk app.js\n```\n\n### Use in Scripts\n\n```bash\n#!/bin/bash\n# test-matrix.sh\n\nVERSIONS=\"18.20.0 20.18.0 22.13.0\"\n\nfor v in $VERSIONS; do\n  echo \"Testing with Node $v...\"\n  vp env exec --node \"$v\" npm test || exit 1\ndone\n\necho \"All tests passed!\"\n```\n\n## List Command (Local)\n\nThe `vp env list` (alias `ls`) command displays locally installed Node.js versions.\n\n### Usage\n\n```bash\n$ vp env list\n* v18.20.0\n* v20.18.0 default\n* v22.13.0 current\n```\n\n- Current version line is highlighted in blue\n- `current` and `default` markers are shown in dimmed text\n\n### Flags\n\n| Flag     | Description    |\n| -------- | -------------- |\n| `--json` | Output as JSON |\n\n### JSON Output\n\n```bash\n$ vp env list --json\n[\n  {\"version\": \"18.20.0\", \"current\": false, \"default\": false},\n  {\"version\": \"20.18.0\", \"current\": false, \"default\": true},\n  {\"version\": \"22.13.0\", \"current\": true, \"default\": false}\n]\n```\n\n### Empty State\n\n```bash\n$ vp env list\nNo Node.js versions installed.\n\nInstall a version with: vp env install <version>\n```\n\n## List-Remote Command\n\nThe `vp env list-remote` (alias `ls-remote`) command displays available Node.js versions from the registry.\n\n### Usage\n\n```bash\n# List recent versions (default: last 10 major versions, ascending order)\n$ vp env list-remote\nv20.0.0\nv20.1.0\n...\nv20.18.0 (Iron)\nv22.0.0\n...\nv22.13.0 (Jod)\nv24.0.0\n\n# List only LTS versions\n$ vp env list-remote --lts\n\n# Filter by major version\n$ vp env list-remote 20\n\n# Show all versions\n$ vp env list-remote --all\n\n# Sort newest first\n$ vp env list-remote --sort desc\n```\n\n### Flags\n\n| Flag                 | Description                         |\n| -------------------- | ----------------------------------- |\n| `--lts`              | Show only LTS versions              |\n| `--all`              | Show all versions (not just recent) |\n| `--json`             | Output as JSON                      |\n| `--sort <asc\\|desc>` | Sorting order (default: asc)        |\n\n### JSON Output\n\n```bash\n$ vp env list-remote --json\n{\n  \"versions\": [\n    {\"version\": \"24.0.0\", \"lts\": false, \"latest\": true},\n    {\"version\": \"22.13.0\", \"lts\": \"Jod\", \"latest_lts\": true},\n    {\"version\": \"22.12.0\", \"lts\": \"Jod\", \"latest_lts\": false},\n    ...\n  ]\n}\n```\n\n### Current Command (JSON)\n\n```bash\n$ vp env --current --json\n{\n  \"version\": \"20.18.0\",\n  \"source\": \".node-version\",\n  \"project_root\": \"/Users/user/projects/my-app\",\n  \"node_path\": \"/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/node\",\n  \"tool_paths\": {\n    \"node\": \"/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/node\",\n    \"npm\": \"/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/npm\",\n    \"npx\": \"/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/npx\"\n  }\n}\n```\n\n## Environment Variables\n\n| Variable                        | Description                                                                                     | Default        |\n| ------------------------------- | ----------------------------------------------------------------------------------------------- | -------------- |\n| `VITE_PLUS_HOME`                | Base directory for bin and config                                                               | `~/.vite-plus` |\n| `VITE_PLUS_NODE_VERSION`        | Session override for Node.js version (set by `vp env use`)                                      | unset          |\n| `VITE_PLUS_LOG`                 | Log level: debug, info, warn, error                                                             | `warn`         |\n| `VITE_PLUS_DEBUG_SHIM`          | Enable extra shim diagnostics                                                                   | unset          |\n| `VITE_PLUS_BYPASS`              | PATH-style list of bin dirs to skip when finding system tools; set `=1` to bypass shim entirely | unset          |\n| `VITE_PLUS_TOOL_RECURSION`      | **Internal**: Prevents shim recursion                                                           | unset          |\n| `VITE_PLUS_ENV_USE_EVAL_ENABLE` | **Internal**: Set by shell wrappers to signal that `vp env use` output will be eval'd           | unset          |\n\n## Unix-Specific Considerations\n\n### Shim Structure\n\n```\nVITE_PLUS_HOME/\n├── bin/\n│   ├── vp -> ../current/bin/vp      # Symlink to actual binary\n│   ├── node -> ../current/bin/vp    # Symlink to same binary\n│   ├── npm -> ../current/bin/vp     # Symlink to same binary\n│   ├── npx -> ../current/bin/vp     # Symlink to same binary\n│   └── tsc -> ../current/bin/vp     # Symlink for global package\n└── current/\n    └── bin/\n        └── vp                        # The actual vp CLI binary\n```\n\n### How argv[0] Detection Works\n\nWhen a user runs `node`:\n\n1. Shell finds `~/.vite-plus/bin/node` in PATH\n2. This is a symlink to `../current/bin/vp`\n3. Kernel resolves symlink and executes `vp` binary\n4. `argv[0]` is set to the invoking path: `node` (or full path)\n5. `vp` binary extracts tool name from `argv[0]` (gets \"node\")\n6. Dispatches to shim logic for node\n\n**Key Insight**: Symlinks preserve argv[0]. This is the same pattern Volta uses successfully.\n\n### Symlink Creation\n\nAll shims use relative symlinks:\n\n```bash\n# Core tools\nln -sf ../current/bin/vp ~/.vite-plus/bin/node\nln -sf ../current/bin/vp ~/.vite-plus/bin/npm\nln -sf ../current/bin/vp ~/.vite-plus/bin/npx\n\n# Global package binaries\nln -sf ../current/bin/vp ~/.vite-plus/bin/tsc\n```\n\n## Windows-Specific Considerations\n\n### Shim Structure\n\n```\nVITE_PLUS_HOME\\\n├── bin\\\n│   ├── vp.exe       # Trampoline forwarding to current\\bin\\vp.exe\n│   ├── node.exe     # Trampoline shim (sets VITE_PLUS_SHIM_TOOL=node)\n│   ├── npm.exe      # Trampoline shim (sets VITE_PLUS_SHIM_TOOL=npm)\n│   ├── npx.exe      # Trampoline shim (sets VITE_PLUS_SHIM_TOOL=npx)\n│   └── tsc.exe      # Trampoline shim for global package\n└── current\\\n    └── bin\\\n        ├── vp.exe       # The actual vp CLI binary\n        └── vp-shim.exe  # Trampoline template (copied as shims)\n```\n\n### Trampoline Executables\n\nWindows shims use lightweight trampoline `.exe` files (see [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md)). Each trampoline detects its tool name from its own filename, sets `VITE_PLUS_SHIM_TOOL`, and spawns `vp.exe`. This avoids the \"Terminate batch job (Y/N)?\" prompt from `.cmd` wrappers and works in all shells (cmd.exe, PowerShell, Git Bash) without needing separate wrapper formats.\n\n#### Why Not Symlinks?\n\nOn Unix, shims are symlinks to the vp binary, which preserves argv[0] for tool detection. On Windows, we use explicit `vp env exec <tool>` calls instead of symlinks because:\n\n1. **Admin privileges required**: Windows symlinks need admin rights or Developer Mode\n2. **Unreliable Git Bash support**: Symlink emulation varies by Git for Windows version\n\nInstead, trampoline `.exe` files are used. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md) for the full design.\n\n**How it works**:\n\n1. User runs `npm install`\n2. Windows finds `~/.vite-plus/bin/npm.exe` in PATH\n3. Trampoline sets `VITE_PLUS_SHIM_TOOL=npm` and spawns `vp.exe`\n4. `vp env exec` command handles version resolution and execution\n\n**Benefits of this approach**:\n\n- Single `vp.exe` binary to update in `current\\bin\\`\n- All shims are trivial `.cmd` text files and shell scripts (no binary copies)\n- Consistent with Volta's Windows approach\n- Clear, readable wrapper scripts\n- Works in both cmd.exe/PowerShell and Git Bash\n\n### Windows Installation (install.ps1)\n\nThe Windows installer (`install.ps1`) follows this flow:\n\n1. Download and install `vp.exe` and `vp-shim.exe` to `~/.vite-plus/current/bin/`\n2. Create `~/.vite-plus/bin/vp.exe` trampoline (copy of `vp-shim.exe`)\n3. Create shim trampolines: `node.exe`, `npm.exe`, `npx.exe` (via `vp env setup`)\n4. Configure User PATH to include `~/.vite-plus/bin`\n\n## Testing Strategy\n\n### Unit Tests\n\n- Tool name extraction from argv[0]\n- Cache invalidation based on mtime\n- PATH manipulation\n- Shim mode loading\n\n### Integration Tests\n\n- Shim dispatch with version resolution\n- Concurrent installation handling\n- Doctor diagnostic output\n\n### Snap Tests\n\nAdd snap tests in `packages/global/snap-tests/`:\n\n```\nenv-setup/\n├── package.json\n├── steps.json      # [{\"command\": \"vp env setup\"}]\n└── snap.txt\n\nenv-doctor/\n├── package.json\n├── .node-version   # \"20.18.0\"\n├── steps.json      # [{\"command\": \"vp env doctor\"}]\n└── snap.txt\n```\n\n### CI Matrix\n\n- ubuntu-latest: Full integration tests\n- macos-latest: Full integration tests\n- windows-latest: Full integration tests with trampoline `.exe` shim validation\n\n## Security Considerations\n\n1. **Path Validation**: Verify executed binaries are under VITE_PLUS_HOME/cache paths\n2. **No Path Traversal**: Sanitize version strings before path construction\n3. **Atomic Installs**: Use temp directory + rename pattern (already implemented)\n4. **Log Sanitization**: Don't log sensitive environment variables\n\n## Implementation Plan\n\n### Phase 1: Core Infrastructure (P0)\n\n1. Add `vp env` command structure to CLI\n2. Implement argv[0] detection in main.rs\n3. Implement shim dispatch logic for `node`\n4. Implement `vp env setup` (Unix symlinks, Windows trampoline `.exe` shims)\n5. Implement `vp env doctor` basic diagnostics\n6. Add resolution cache (persists across upgrades with version field)\n7. Implement `vp env default [version]` to set/show global default Node.js version\n8. Implement `vp env on` and `vp env off` for shim mode control\n9. Implement `vp env pin [version]` for per-directory version pinning\n10. Implement `vp env unpin` as alias for `pin --unpin`\n11. Implement `vp env list` (local) and `vp env list-remote` (remote) to show versions\n12. Implement recursion prevention (`VITE_PLUS_TOOL_RECURSION`)\n13. Implement `vp env exec --node <version>` command\n\n### Phase 2: Full Tool Support (P1)\n\n1. Add shims for `npm`, `npx`\n2. Implement `vp env which`\n3. Implement `vp env --current --json`\n4. Enhanced doctor with conflict detection\n5. Implement `vp install -g` / `vp remove -g` / `vp update -g` for managed global packages\n6. Implement package metadata storage\n7. Implement per-package binary shims\n8. Implement `vp list -g` / `vp pm list -g` to list installed global packages\n9. Implement `vp env install <VERSION>` to install Node.js versions\n10. Implement `vp env uninstall <VERSION>` to uninstall Node.js versions\n11. Implement per-binary config files (`bins/`) for conflict detection\n12. Implement binary conflict detection (hard fail by default)\n13. Implement `--force` flag for auto-uninstall on conflict\n14. Implement two-phase uninstall with orphan recovery\n\n### Phase 3: Polish (P2)\n\n1. Implement `vp env --print` for session-only env\n2. Add VITE_PLUS_BYPASS escape hatch\n3. Improve error messages\n4. Add IDE-specific setup guidance\n5. Documentation\n\n### Phase 4: Future Enhancements (P3)\n\n1. NODE_PATH setup for shared package resolution\n\n## Backward Compatibility\n\nThis is a new feature with no impact on existing functionality. The `vp` binary continues to work normally when invoked directly.\n\n## Future Enhancements\n\n1. **Multiple Runtime Support**: Extend shim architecture for other runtimes (Bun, Deno)\n2. **SQLite Cache**: Replace JSON cache with SQLite for better performance at scale\n3. **Shell Integration**: Provide shell hooks for prompt version display\n\n## Design Decisions Summary\n\nThe following decisions have been made:\n\n1. **VITE_PLUS_HOME Default Location**: `~/.vite-plus` - Simple, memorable path that's easy for users to find and configure.\n\n2. **Windows Shim Strategy**: Trampoline `.exe` files that set `VITE_PLUS_SHIM_TOOL` and spawn `vp.exe` - Avoids \"Terminate batch job?\" prompt, works in all shells. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md).\n\n3. **Corepack Handling**: Not included - vite-plus has integrated package manager functionality, making corepack shims unnecessary.\n\n4. **Cache Persistence**: Persist across upgrades - Better performance, with cache format versioning for compatibility.\n\n## Conclusion\n\nThe `vp env` command provides:\n\n- ✅ System-wide Node version management via shims\n- ✅ IDE-safe operation (works with GUI-launched apps)\n- ✅ Zero daily friction (automatic version switching)\n- ✅ Cross-platform support (Windows, macOS, Linux)\n- ✅ Comprehensive diagnostics (`doctor`)\n- ✅ Flexible shim mode control (`on`/`off` for managed vs system-first)\n- ✅ Easy version pinning per project (`pin`/`unpin`)\n- ✅ Version discovery with `list` command\n- ✅ Leverages existing version resolution and installation infrastructure\n"
  },
  {
    "path": "rfcs/exec-command.md",
    "content": "# RFC: `vp exec` Command\n\n## Summary\n\nAdd `vp exec` as a subcommand that prepends `./node_modules/.bin` to PATH and executes a command. This is the equivalent of `pnpm exec`.\n\nThe command completes the execution story alongside existing commands:\n\n| Command       | Behavior                                                       | Analogy         |\n| ------------- | -------------------------------------------------------------- | --------------- |\n| `vp dlx`      | Always downloads from remote                                   | `pnpm dlx`      |\n| `vpx`         | Local → global → PATH → remote fallback                        | `npx`           |\n| **`vp exec`** | **Prepend `node_modules/.bin` to PATH, then execute normally** | **`pnpm exec`** |\n\n## Motivation\n\nCurrently, to run a command with `node_modules/.bin` on PATH, developers must use `vpx` (which has global/remote fallback) or call `./node_modules/.bin/<cmd>` directly. There is no simple way to prepend the local bin directory to PATH and execute — the behavior that `pnpm exec` provides.\n\n### Why `vp exec` Is Needed\n\n1. **No remote fallback**: Unlike `vpx`, `vp exec` never downloads from the registry — commands resolve via `node_modules/.bin` + existing PATH only\n2. **Workspace iteration**: `pnpm exec --recursive` runs a command in every workspace package — `vpx` doesn't support this\n3. **pnpm exec parity**: Projects migrating from pnpm expect `exec` to exist as a subcommand\n4. **Explicit intent**: `vp exec` means \"run with local bins on PATH\" vs `vpx` which means \"find it anywhere, download if needed\"\n\n### Current Pain Points\n\n```bash\n# Developer wants to run with node_modules/.bin on PATH, no remote fallback\nvpx eslint .                           # Has remote fallback — may download unexpectedly\n./node_modules/.bin/eslint .           # Verbose, not portable\n\n# Developer wants to run a command in every workspace package\npnpm exec --recursive -- eslint .      # Works with pnpm\n# No vp equivalent exists today\n```\n\n### Proposed Solution\n\n```bash\n# Run with node_modules/.bin on PATH (no remote fallback)\nvp exec eslint .\n\n# Run in every workspace package\nvp exec --recursive -- eslint .\n\n# Shell mode\nvp exec -c 'echo $PATH'\n```\n\n## Command Syntax\n\n```bash\nvp exec [OPTIONS] [--] <command> [args...]\n```\n\nThe leading `--` is optional and stripped for backward compatibility (matching pnpm exec behavior).\n\n**Options:**\n\n- `--shell-mode, -c` — Execute within a shell environment (`/bin/sh` on UNIX, `cmd.exe` on Windows)\n- `--recursive, -r` — Run in every workspace package (local CLI only)\n- `--workspace-root, -w` — Run on the workspace root package only (local CLI only)\n- `--filter, -F <selector>` — Filter packages by name pattern or relative path (local CLI only); also accepts `--filter=<selector>` form\n- `--parallel` — Run concurrently without topological sort (local CLI only)\n- `--reverse` — Reverse topological order (local CLI only)\n- `--resume-from <pkg>` — Resume from a specific package (local CLI only); also accepts `--resume-from=<pkg>` form\n- `--report-summary` — Save results to `vp-exec-summary.json` (local CLI only)\n\n### Usage Examples\n\n```bash\n# Basic: run locally installed binary\nvp exec eslint .\n\n# With arguments\nvp exec tsc --noEmit\n\n# Shell mode (pipe commands, expand variables)\nvp exec -c 'echo $PATH'\nvp exec -c 'eslint . && prettier --check .'\n\n# Run in every workspace package\nvp exec -r -- eslint .\n\n# Filter to specific packages\nvp exec --filter 'app...' -- tsc --noEmit\n\n# Filter by relative path\nvp exec --filter ./packages/app-a -- tsc --noEmit\n\n# Braced path filter with dependency traversal\nvp exec --filter '{./packages/app-a}...' -- tsc --noEmit\n\n# Run in parallel (no topological ordering)\nvp exec -r --parallel -- eslint .\n\n# Resume from a specific package (after failure)\nvp exec -r --resume-from @my/app -- tsc --noEmit\n\n# Run on workspace root only\nvp exec -w -- node -e \"console.log(process.env.VITE_PLUS_PACKAGE_NAME)\"\n\n# Save execution summary\nvp exec -r --report-summary -- vitest run\n```\n\n## Filter Selector Syntax\n\nThe `--filter` flag supports pnpm-compatible selectors:\n\n**Name patterns:**\n\n- `app-a` — exact package name\n- `app-*` — glob pattern matching package names\n- `@myorg/*` — scoped package glob\n\n**Path selectors** (detected by leading `.` or `..`):\n\n- `./packages/app-a` — match packages whose directory is at or under this path\n- `../other-pkg` — relative path from cwd\n\n**Braced path selectors** (pnpm-compatible syntax):\n\n- `{./packages/app-a}` — equivalent to `./packages/app-a`\n- `{./packages/app-a}...` — path with dependency traversal\n- `...{./packages/app-a}` — path with dependent traversal\n- `app-*{./packages}` — combined name pattern + path filter (match by path first, then filter by name)\n\n**Modifiers:**\n\n- `<selector>...` — include the package and all its transitive dependencies\n- `...<selector>` — include the package and all packages that depend on it\n- `<selector>^...` — only dependencies, exclude the matched package itself\n- `...^<selector>` — only dependents, exclude the matched package itself\n- `!<selector>` — exclude matched packages from the result set\n\nModifiers work with name patterns (e.g., `app-a...`) and braced path selectors (e.g., `{./packages/app-a}...`). Unbraced path selectors (e.g., `./packages/app-a`) do not support traversal modifiers.\n\n**Whitespace splitting**: `--filter \"a b\"` is equivalent to `--filter a --filter b` (pnpm compatibility). Each `--filter` value is split by whitespace into individual filter tokens.\n\n**Unmatched filter warning**: When an inclusion filter matches no packages, a warning is emitted to stderr (e.g., `WARN No packages matched the filter 'nonexistent'`).\n\n**Exclusion-only filters**: When all selectors are exclusion-only (e.g., `--filter '!app-b'`), the result is all non-root workspace packages minus the excluded ones. This matches pnpm behavior — exclusion without an explicit inclusion implies \"start with everything\".\n\n**`-w --filter` interaction**: `-w` (workspace root) combined with `--filter` is additive — the workspace root is included alongside the filtered packages. This matches pnpm behavior.\n\n**Workspace root inclusion rules**:\n\n- `-r` (recursive) includes the workspace root along with all workspace packages\n- `-w` (workspace root) runs on the workspace root package only\n- `--filter '*'` includes the workspace root because `*` name-matches all packages including root\n\n## Core Behavior\n\nBased on pnpm exec behavior (reference: `exec/plugin-commands-script-runners/src/exec.ts`):\n\n1. **Prepend `./node_modules/.bin`** (and extra bin paths from the package manager) to `PATH`\n2. **Strip leading `--`** from the command for backward compatibility\n3. **Execute command** via process spawn with `stdio: inherit` — the command resolves through the modified PATH (local bins first, then system PATH)\n4. **Shell mode**: When `-c` is specified, pass `shell: true` to the child process\n5. **Set `VITE_PLUS_PACKAGE_NAME`** env var with the current package name (analogous to pnpm's `PNPM_PACKAGE_NAME`)\n6. **Error if no command**: `'vp exec' requires a command to run`\n\n## Relationship Between Commands\n\n| Behavior             | `vp exec`                        | `vpx`                       | `vp dlx`       |\n| -------------------- | -------------------------------- | --------------------------- | -------------- |\n| Prepend to PATH      | `./node_modules/.bin` (cwd only) | Walk up `node_modules/.bin` | No             |\n| Global vp pkg lookup | No                               | Yes                         | No             |\n| System PATH          | Yes (after `node_modules/.bin`)  | Yes                         | No             |\n| Remote download      | No                               | Yes (fallback)              | Always         |\n| Workspace iteration  | Yes (`-r`, `--filter`)           | No                          | No             |\n| Shell mode           | Yes (`-c`)                       | Yes (`-c`)                  | Yes (`-c`)     |\n| Use case             | Run with local bins on PATH      | Run any tool, find it       | Download & run |\n\n### Key Differences from vpx\n\n- `vp exec` prepends only `./node_modules/.bin` from the current directory — it does **not** walk up parent directories. Use `vpx` if you want monorepo root binaries.\n- `vp exec` never falls back to global vp packages or remote download — commands resolve through `node_modules/.bin` + system PATH only.\n\n## Implementation Architecture\n\n### Global CLI\n\n**File**: `crates/vite_global_cli/src/cli.rs`\n\nThe `Exec` variant in `Commands` enum (Category C) unconditionally delegates to the local CLI:\n\n```rust\n// Category C: Local CLI Delegation\n/// Execute a command from local node_modules/.bin\n#[command(disable_help_flag = true)]\nExec {\n    /// Additional arguments\n    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n    args: Vec<String>,\n},\n```\n\nRoute in `execute_command()`:\n\n```rust\nCommands::Exec { args } => commands::delegate::execute(cwd, \"exec\", &args).await,\n```\n\nThe global CLI always delegates `exec` to the local CLI — there is no fallback path or direct execution in the global CLI. This follows the same unconditional delegation pattern as other Category C commands.\n\n### Local CLI\n\n**Module**: `packages/cli/binding/src/exec/`\n\nThe local CLI receives the `exec` command via delegation from the global CLI (same mechanism as `run`, `build`, etc.). The exec logic is organized into a dedicated module with submodules:\n\n```\npackages/cli/binding/src/exec/\n├── mod.rs       — entry point (execute), delegates to workspace.rs\n├── args.rs      — ExecArgs (clap-derived struct with #[clap(flatten)] PackageQueryArgs)\n└── workspace.rs — execute_exec_workspace(), topological_sort_packages()\n```\n\nThere is a single code path for both single-package and multi-package execution. `mod.rs` validates the command is non-empty and delegates to `execute_exec_workspace()`. When no workspace flags (`--recursive`, `--filter`, etc.) are given, `PackageQueryArgs::into_package_query()` returns a `ContainingPackage(cwd)` selector that resolves to just the current package — so the workspace path naturally handles the single-package case.\n\nPackage filtering is delegated to `vite_workspace`'s reusable API: `PackageQueryArgs` (CLI args struct, embedded via `#[clap(flatten)]`) → `PackageQuery` (via `into_package_query()`) → `IndexedPackageGraph::resolve_query()` → `FilterResolution` (with `package_subgraph` and `unmatched_selectors`). This follows the same pattern used by `vp run` via `RunFlags`.\n\nThe local CLI has full workspace awareness and can handle:\n\n- `--recursive` — iterate workspace packages with topological sort\n- `--filter, -F` — filter packages by selector\n- `--parallel` — run concurrently\n- `--reverse` — reverse topological order\n- `--resume-from` — resume from specific package\n- `--report-summary` — save results JSON\n\nFor the local CLI, exec uses the workspace package graph to iterate packages, prepending each package's `node_modules/.bin` to PATH before spawning the command in that package's directory.\n\nWhen only a single package is selected (whether by default or via `--filter`), the `pkg_name$ cmd` prefix is suppressed from output and command-not-found errors produce a user-friendly message with a hint to run `vp install` or use `vpx`.\n\n### Reusable Code\n\nThe following existing code is reused:\n\n| Module           | Function                           | Purpose                                           |\n| ---------------- | ---------------------------------- | ------------------------------------------------- |\n| `vite_command`   | `resolve_bin()`                    | Resolve binary path via PATH lookup               |\n| `vite_command`   | `build_command()`                  | Build a `tokio::process::Command` for a binary    |\n| `vite_command`   | `build_shell_command()`            | Build a shell command for `-c` mode               |\n| `vite_install`   | `PackageManager::get_bin_prefix()` | Get package manager bin directory for PATH        |\n| `vite_workspace` | `find_workspace_root()`            | Locate workspace root from cwd                    |\n| `vite_workspace` | `load_package_graph()`             | Load workspace packages and dependency graph      |\n| `vite_workspace` | `PackageQueryArgs`                 | CLI args struct for package selection             |\n| `vite_workspace` | `IndexedPackageGraph`              | Indexed graph with `resolve_query()`              |\n| `vite_workspace` | `FilterResolution`                 | Resolution result: subgraph + unmatched selectors |\n\n## Design Decisions\n\n### 1. Unconditional Delegation (No Global CLI Fallback)\n\n**Decision**: The global CLI always delegates `exec` to the local CLI. There is no fallback path for projects without vite-plus as a dependency.\n\n**Rationale**:\n\n- Simplifies the global CLI — no need for a direct-execution codepath\n- Consistent with how all Category C commands are dispatched\n- The local CLI has all the workspace awareness needed for `--recursive`, `--filter`, etc.\n- Projects using `vp exec` are expected to have vite-plus installed\n\n### 2. No Directory Walk-Up (Unlike vpx)\n\n**Decision**: `vp exec` only checks `./node_modules/.bin` in the current directory, not parent directories.\n\n**Rationale**:\n\n- Matches `pnpm exec` behavior — strict local scope\n- In workspace iteration (`-r`), each package should use its own `node_modules/.bin`\n- Walking up would blur the boundary between package-level and workspace-level binaries\n- Use `vpx` if you want walk-up behavior\n\n### 3. Workspace Features Only via Local CLI\n\n**Decision**: `--recursive`, `--workspace-root`, `--filter`, `--parallel`, `--reverse`, `--resume-from`, and `--report-summary` only work when vite-plus is a local dependency (local CLI handles them).\n\n**Rationale**:\n\n- These features require workspace awareness from vite-task infrastructure\n- The global CLI fallback is for simple, single-directory exec\n- This is consistent with how `vp run` handles workspace features\n\n### 4. Same Env Var Convention\n\n**Decision**: Set `VITE_PLUS_PACKAGE_NAME` env var when executing in a workspace package.\n\n**Rationale**:\n\n- Follows pnpm's `PNPM_PACKAGE_NAME` convention\n- Allows scripts to know which package they're running in\n- Consistent naming with vite-plus branding\n\n### 5. Strip Leading `--`\n\n**Decision**: Automatically strip a leading `--` from the command arguments.\n\n**Rationale**:\n\n- Matches pnpm exec backward compatibility behavior\n- `vp exec -- eslint .` and `vp exec eslint .` should behave identically\n- Reduces friction for users coming from pnpm\n\n### 6. Execution Ordering\n\n**Decision**: When `--recursive` or `--filter` is used, packages execute in topological order (dependencies first). The topological sort uses `petgraph::algo::toposort` on the `FilterResolution.package_subgraph` (not the original full graph), enabling future `--filter-prod` support where dev dependency edges are excluded at subgraph construction time.\n\n**Rationale**:\n\n- **Topological ordering by default**: Commands like `tsc --noEmit` or `build` need dependencies to complete before dependents. Running in dependency order ensures correctness without requiring users to specify `--topological` explicitly.\n- **No alphabetical tie-breaking**: Packages with no ordering constraint between them (e.g., two unrelated leaf packages) are ordered by petgraph's internal traversal order. This matches pnpm's behavior.\n- **`--parallel` skips ordering**: In parallel mode, all packages are spawned concurrently — topological order only affects the order of output collection.\n- **`--reverse`**: Reverses the topological order (dependents first, then dependencies). Useful for cleanup operations.\n- **Circular dependency handling**: When cycles exist, `toposort()` returns an error. The fallback uses `petgraph::algo::tarjan_scc`, which returns strongly connected components (SCCs) in reverse topological order of the condensed DAG. This preserves correct ordering for non-cyclic dependencies even when cycles are present — nodes outside a cycle are correctly placed before or after the cycle based on their dependency relationship.\n\n  **Example — normal dependency chain (no cycle):**\n\n  ```\n  a → b → c → d → e    (a depends on b, b depends on c, ...)\n\n  toposort produces dependencies-first order:\n  Result: [e, d, c, b, a]\n  ```\n\n  **Example — simple cycle (2 nodes):**\n\n  ```\n  a ←→ b    (mutual dependency)\n\n  toposort returns Err(Cycle).\n  tarjan_scc returns [{a, b}] — one SCC containing both nodes.\n  Result: [a, b] or [b, a]  (intra-SCC order is arbitrary)\n  ```\n\n  **Example — 3-node cycle:**\n\n  ```\n  a → b → c → a    (a depends on b, b depends on c, c depends on a)\n\n  toposort returns Err(Cycle).\n  tarjan_scc returns [{a, b, c}] — all three form one SCC.\n  Result: [a, b, c] in any permutation  (intra-SCC order is arbitrary)\n  ```\n\n  **Example — cycle with a non-cyclic dependency:**\n\n  ```\n  a ←→ b, a → c    (a↔b cycle, a depends on non-cyclic c)\n\n  toposort returns Err(Cycle).\n  tarjan_scc returns [{c}, {a, b}] — c as its own SCC first, then\n  the a↔b cycle.  Dependencies-first order is preserved.\n  Result: [c, a, b] or [c, b, a]  (c always before the cycle)\n  ```\n\n  **Example — cycle with a non-cyclic dependent:**\n\n  ```\n  a ←→ b ← aa    (a↔b cycle, aa depends on b)\n\n  toposort returns Err(Cycle).\n  tarjan_scc returns [{a, b}, {aa}] — the cycle SCC first, then aa.\n  Result: [a, b, aa] or [b, a, aa]  (cycle always before aa)\n  ```\n\n- **Platform-safe PATH construction**: PATH environment variable is constructed using `std::env::join_paths()` instead of hardcoded `:` separator, ensuring correct behavior on both Unix (`:`) and Windows (`;`).\n\n## CLI Help Output\n\n```bash\n$ vp exec --help\nExecute a command from local node_modules/.bin\n\nUsage: vp exec [OPTIONS] [--] <command> [args...]\n\nArguments:\n  <command>  Command to execute from node_modules/.bin\n  [args...]  Arguments to pass to the command\n\nOptions:\n  -c, --shell-mode              Execute the command within a shell environment\n  -r, --recursive               Run in every workspace package\n  -w, --workspace-root          Run on the workspace root package only\n  -F, --filter <PATTERN>        Filter packages (can be used multiple times)\n      --parallel                Run concurrently without topological ordering\n      --reverse                 Reverse execution order\n      --resume-from <PACKAGE>   Resume from a specific package\n      --report-summary          Save results to vp-exec-summary.json\n  -h, --help                    Print help\n\nExamples:\n  vp exec eslint .                            # Run local eslint\n  vp exec tsc --noEmit                        # Run local TypeScript compiler\n  vp exec -c 'eslint . && prettier --check .' # Shell mode\n  vp exec -r -- eslint .                      # Run in all workspace packages\n  vp exec --filter 'app...' -- tsc            # Run in filtered packages\n```\n\n## Error Handling\n\n### Missing Command\n\n```bash\n$ vp exec\nError: 'vp exec' requires a command to run\n\nUsage: vp exec [--] <command> [args...]\n\nExamples:\n  vp exec eslint .\n  vp exec tsc --noEmit\n```\n\n### Command Not Found\n\n```bash\n$ vp exec nonexistent-cmd\nError: Command 'nonexistent-cmd' not found\n\nHint: Run 'vp install' to install dependencies, or use 'vpx' for remote fallback.\n```\n\n## Snap Tests\n\n### Global CLI Test: `command-exec-pnpm10`\n\n**Location**: `packages/cli/snap-tests-global/command-exec-pnpm10/`\n\n```\ncommand-exec-pnpm10/\n├── package.json\n├── steps.json\n└── snap.txt          # auto-generated\n```\n\n**`package.json`**:\n\n```json\n{\n  \"name\": \"command-exec-pnpm10\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\"\n}\n```\n\n**`steps.json`**:\n\n```json\n{\n  \"env\": {\n    \"VITE_DISABLE_AUTO_INSTALL\": \"1\"\n  },\n  \"commands\": [\n    \"vp exec echo hello # basic exec, no vite-plus dep (global CLI handles directly)\",\n    \"vp exec node -e \\\"console.log('hi')\\\" # exec with args passthrough\",\n    \"vp exec nonexistent-cmd # command not found error\",\n    \"vp exec -c 'echo hello from shell' # shell mode\"\n  ]\n}\n```\n\n**Test cases**:\n\n1. `vp exec echo hello` — basic execution with a command found on PATH after `node_modules/.bin` prepend\n2. `vp exec node -e \"console.log('hi')\"` — argument passthrough to a multi-arg command\n3. `vp exec nonexistent-cmd` — command-not-found error message\n4. `vp exec -c 'echo hello from shell'` — shell mode execution\n\n### Local CLI Test: `command-exec`\n\n**Location**: `packages/cli/snap-tests/command-exec/`\n\n```\ncommand-exec/\n├── package.json\n├── steps.json\n└── snap.txt          # auto-generated\n```\n\n**`package.json`**:\n\n```json\n{\n  \"name\": \"command-exec\",\n  \"version\": \"1.0.0\",\n  \"packageManager\": \"pnpm@10.19.0\",\n  \"devDependencies\": {\n    \"vite-plus\": \"workspace:*\",\n    \"cowsay\": \"^1.6.0\"\n  }\n}\n```\n\n**`steps.json`**:\n\n```json\n{\n  \"commands\": [\n    \"vp exec cowsay hello # exec with installed binary\",\n    \"vp exec -c 'echo $PATH' # verify PATH includes node_modules/.bin\"\n  ]\n}\n```\n\n**Test cases**:\n\n1. `vp exec cowsay hello` — execute locally installed binary via local CLI delegation\n2. `vp exec -c 'echo $PATH'` — verify that `node_modules/.bin` is prepended to PATH\n\n## Edge Cases\n\n### Leading `--` Stripping\n\n```bash\n# Both are equivalent\nvp exec -- eslint .\nvp exec eslint .\n```\n\n### Shell Mode with Complex Commands\n\n```bash\n# Pipes and redirects require shell mode\nvp exec -c 'eslint . 2>&1 | tee lint-output.txt'\n\n# Environment variable expansion\nvp exec -c 'echo $NODE_ENV'\n```\n\n### Recursive with Failures\n\nWhen running recursively, a failure in one package stops execution (unless `--parallel` is used, in which case all packages run and failures are collected):\n\n```bash\n$ vp exec -r -- tsc --noEmit\n@my/utils: tsc --noEmit ... ok\n@my/app: tsc --noEmit ... FAILED (exit code 1)\nError: 1 of 5 packages failed\n```\n\n### Empty args After `--`\n\n```bash\n$ vp exec --\nError: 'vp exec' requires a command to run\n```\n\n## Security Considerations\n\n1. **No remote fallback**: Unlike `vpx`, `vp exec` never downloads from the registry, eliminating supply chain risk from accidental remote execution\n2. **PATH behavior**: Commands resolve through `./node_modules/.bin` (prepended) + system PATH. This matches `pnpm exec` behavior — system commands like `echo`, `node`, etc. are still reachable\n3. **Shell mode risks**: Shell mode (`-c`) allows arbitrary shell commands — same considerations as pnpm exec\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing `vp dlx` and `vpx` behavior unchanged\n- New `exec` subcommand is purely additive\n- No changes to configuration format\n- Follows established delegation pattern (like `vp run`)\n\n## Comparison with pnpm exec\n\n| Behavior              | `pnpm exec`                              | `vp exec`                                |\n| --------------------- | ---------------------------------------- | ---------------------------------------- |\n| PATH modification     | Prepend `./node_modules/.bin`            | Prepend `./node_modules/.bin`            |\n| Command resolution    | Modified PATH (local bins + system PATH) | Modified PATH (local bins + system PATH) |\n| Walk-up               | No                                       | No                                       |\n| Shell mode (`-c`)     | Yes                                      | Yes                                      |\n| Recursive (`-r`)      | Yes (workspace iteration)                | Yes (via local CLI)                      |\n| Workspace root (`-w`) | Yes (root only)                          | Yes (root only)                          |\n| Filter                | `--filter`                               | `--filter`                               |\n| Path-based filter     | `--filter ./packages/app`                | `--filter ./packages/app`                |\n| Braced path filter    | `--filter {./packages/app}`              | `--filter {./packages/app}`              |\n| Name + path filter    | `--filter 'app-*{./packages}'`           | `--filter 'app-*{./packages}'`           |\n| Parallel              | `--parallel`                             | `--parallel`                             |\n| Report summary        | `--report-summary`                       | `--report-summary`                       |\n| Package name env var  | `PNPM_PACKAGE_NAME`                      | `VITE_PLUS_PACKAGE_NAME`                 |\n| Strip leading `--`    | Yes                                      | Yes                                      |\n\n## Future Enhancements\n\n### 1. `--if-present` Flag\n\n```bash\n# Skip packages where the command doesn't exist (useful with -r)\nvp exec -r --if-present -- eslint .\n```\n\n## Conclusion\n\nThis RFC proposes adding `vp exec` to complete the execution command trio in Vite+:\n\n- `vp dlx` — always remote (like `pnpm dlx`)\n- `vpx` — local-first with fallback chain (like `npx`)\n- `vp exec` — prepend local bins to PATH, no remote fallback (like `pnpm exec`)\n\nThe design:\n\n- Matches `pnpm exec` semantics for familiar developer experience\n- Follows the established unconditional delegation pattern for global/local CLI routing\n- Reuses existing infrastructure (`vpx.rs` helpers, delegation, PATH manipulation)\n- Supports workspace features (recursive, filter, parallel) via local CLI\n- Is purely additive with no breaking changes\n"
  },
  {
    "path": "rfcs/global-cli-rust-binary.md",
    "content": "# RFC: Global CLI Rust Binary\n\n## Status\n\nImplemented\n\n## Background\n\nCurrently, the vite+ global CLI (`vite-plus-cli` in `packages/global`) uses Node.js as its entry point:\n\n```\nbin/vite (shell script) → src/index.ts (Node.js) → Rust bindings (NAPI)\n```\n\nThis architecture requires users to have Node.js pre-installed before they can use the global CLI. While the core functionality is already implemented in Rust via NAPI bindings, the Node.js requirement creates friction for new users who want to try vite+.\n\n### Current Pain Points\n\n1. **Installation Prerequisite**: Users must install Node.js before using vite+\n2. **Version Compatibility**: Different Node.js versions may cause compatibility issues\n3. **Onboarding Friction**: New users cannot simply download and run the CLI\n4. **Distribution Complexity**: Need to manage both npm package and native bindings\n\n### Opportunity\n\nThe `vite_js_runtime` crate already provides robust Node.js download and management capabilities:\n\n- Automatic Node.js version resolution and download\n- Multi-platform support (Linux, macOS, Windows; x64, arm64)\n- Intelligent caching with ETag support\n- Hash verification for security\n- Per-project version control via `devEngines.runtime` in package.json\n\nBy making the global CLI a Rust binary entry point:\n\n1. **Users can download and run it immediately** without pre-installing Node.js\n2. **Projects control their JS runtime version** via `devEngines.runtime` configuration\n3. **Consistent development environments** across teams - everyone uses the same runtime version\n4. **No system-wide Node.js conflicts** - each project can specify its required version\n\nThe core innovation is enhancing JS runtime management, not eliminating Node.js usage. The CLI will automatically download and manage Node.js to execute package managers and JS scripts.\n\n## Goals\n\n1. **Remove Node.js installation prerequisite**: Create a standalone Rust binary that users can download and run immediately, without needing to pre-install Node.js on their system\n2. **Enhanced JS Runtime Management**: Use `vite_js_runtime` to automatically download, cache, and manage Node.js versions, enabling:\n   - Automatic Node.js provisioning for package manager and CLI operations\n   - Per-project runtime version control via `devEngines.runtime` in package.json\n   - Consistent runtime versions across development environments\n3. **Maintain current functionality**: All commands from `packages/global` continue to work via bundled JS scripts\n4. **Maintain backward compatibility**: Existing command-line interface and behaviors remain unchanged\n5. **Cross-platform distribution**: Support Linux, macOS, and Windows via platform-specific binaries\n\n## Non-Goals\n\n1. Replacing the local CLI (`packages/cli`) - that remains a Node.js package\n2. Removing the NAPI bindings - they will coexist for the local CLI use case\n3. Changing the command syntax or behavior\n4. Supporting JavaScript-only execution mode (always uses managed runtime)\n\n## User Stories\n\n### Story 1: First-time User Installation\n\n```bash\n# Before (requires Node.js)\nnpm install -g vite-plus-cli\nvp create my-app\n\n# After (no Node.js required)\ncurl -fsSL https://vite.plus | bash\n# or\nbrew install vite-plus\n# or download binary directly\n\nvp create my-app  # Works immediately\n```\n\n### Story 2: Running Package Manager Commands\n\n```bash\n# User runs install command (no Node.js pre-installed on system)\nvp install lodash\n\n# CLI automatically:\n# 1. Checks if managed Node.js is cached\n# 2. Downloads Node.js 22.22.0 if not present\n# 3. Detects workspace package manager (pnpm/npm/yarn)\n# 4. Downloads package manager if needed\n# 5. Executes: node /path/to/pnpm install lodash\n```\n\n**Note:** Package managers (pnpm, npm, yarn) are Node.js programs, so the CLI uses managed Node.js to run them. The key benefit is that users don't need to pre-install Node.js - the CLI handles it automatically.\n\n### Story 3: Commands Requiring JavaScript Execution\n\n```bash\n# User runs a command that needs JS\nvp create --template create-vite my-app\n\n# CLI automatically:\n# 1. Checks if managed Node.js is cached\n# 2. Downloads Node.js 22.22.0 if not present\n# 3. Executes create-vite using managed Node.js\n```\n\n## Technical Design\n\n### New Crate: `vite_global_cli`\n\nCreate a new crate at `crates/vite_global_cli` that compiles to a standalone binary.\n\n```\ncrates/\n├── vite_global_cli/         # New crate\n│   ├── Cargo.toml\n│   └── src/\n│       ├── main.rs          # Entry point\n│       ├── cli.rs           # CLI parsing (clap)\n│       ├── commands/        # Command implementations\n│       │   ├── mod.rs\n│       │   ├── pm.rs        # Package manager commands\n│       │   ├── new.rs       # Project scaffolding\n│       │   ├── migrate.rs   # Migration command\n│       │   └── ...\n│       ├── js_executor.rs   # JS execution via vite_js_runtime\n│       └── workspace.rs     # Workspace detection (reuse from vite_task)\n├── vite_js_runtime/         # Existing - Node.js management\n├── vite_task/               # Existing - Task execution\n└── ...\n```\n\n### Command Categories\n\nBased on the current global CLI analysis, commands fall into four categories:\n\n#### Category A: Package Manager Commands (Rust CLI + Managed Node.js)\n\nThese commands wrap existing package managers (pnpm/npm/yarn), which are Node.js programs. The Rust CLI handles argument parsing and workspace detection, then uses managed Node.js to execute the actual package manager:\n\n| Command               | Description          | Implementation                             |\n| --------------------- | -------------------- | ------------------------------------------ |\n| `install [packages]`  | Install dependencies | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `add <packages>`      | Add packages         | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `remove <packages>`   | Remove packages      | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `update [packages]`   | Update packages      | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `outdated [packages]` | Check outdated       | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `dedupe`              | Deduplicate deps     | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `why <package>`       | Explain dependency   | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `info <package>`      | View package info    | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `link [package]`      | Link packages        | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `unlink [package]`    | Unlink packages      | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n| `dlx <package>`       | Execute package      | Rust CLI → Managed Node.js → pnpm/npm dlx  |\n| `pm <subcommand>`     | Forward to PM        | Rust CLI → Managed Node.js → pnpm/npm/yarn |\n\n**Note:** Since pnpm, npm, and yarn are all Node.js programs, these commands require Node.js to execute. The global CLI will use `vite_js_runtime` to download and manage Node.js automatically when running any PM command.\n\n#### Category B: JS Script Commands (Rust CLI + Managed Node.js + JS Scripts)\n\nThese commands execute JavaScript scripts bundled with the CLI:\n\n| Command          | JS Dependency                        | Implementation                          |\n| ---------------- | ------------------------------------ | --------------------------------------- |\n| `new [template]` | Remote templates (create-vite, etc.) | Rust CLI → Managed Node.js → JS scripts |\n| `migrate [path]` | Migration rules and transformations  | Rust CLI → Managed Node.js → JS scripts |\n| `--version`      | Version display logic                | Rust CLI → Managed Node.js → JS scripts |\n\n#### Category C: Local CLI Delegation (Rust CLI + Managed Node.js + JS Entry Point)\n\nThese commands delegate to the local `vite-plus` package through the JS entry point (`dist/index.js`), which handles detecting/installing local vite-plus:\n\n| Command                                                          | Implementation                                           |\n| ---------------------------------------------------------------- | -------------------------------------------------------- |\n| `dev`, `build`, `test`, `lint`, `fmt`, `run`, `preview`, `cache` | Rust CLI → Managed Node.js → `dist/index.js` → local CLI |\n\n**Note:** The global CLI uses `vite_js_runtime` to ensure Node.js is available, resolving the version from the project's `devEngines.runtime` configuration. The JS entry point handles detecting if vite-plus is installed locally and delegating to the local CLI's `dist/bin.js`.\n\n#### Category D: Pure Rust Commands (No Node.js Required)\n\nOnly these commands can run without any Node.js:\n\n| Command | Description | Implementation   |\n| ------- | ----------- | ---------------- |\n| `help`  | Show help   | Pure Rust (clap) |\n\n**Note:** Even `help` might trigger Node.js download if the user runs `vite help new` and needs to display JS-specific help.\n\n### Architecture\n\n```\n┌──────────────────────────────────────────────────────────────────────────────┐\n│                        vite_global_cli (Rust Binary)                         │\n├──────────────────────────────────────────────────────────────────────────────┤\n│                                                                              │\n│  ┌──────────────────┐  ┌──────────────────┐  ┌──────────────────────────┐   │\n│  │   CLI Parser     │  │ Workspace Detect │  │   VITE_GLOBAL_CLI_JS_SCRIPTS_DIR│   │\n│  │   (clap)         │  │ (from vite_task) │  │   (bundled scripts path) │   │\n│  └────────┬─────────┘  └────────┬─────────┘  └────────────┬─────────────┘   │\n│           │                     │                         │                 │\n│  ┌────────▼─────────────────────▼─────────────────────────▼───────────────┐ │\n│  │                          Command Router                                 │ │\n│  └───┬──────────────────┬──────────────────┬──────────────────┬───────────┘ │\n│      │                  │                  │                  │             │\n│  ┌───▼────────────┐ ┌───▼────────────┐ ┌───▼────────────┐ ┌───▼──────────┐ │\n│  │ Category A     │ │ Category B     │ │ Category C     │ │ Category D   │ │\n│  │ PM Commands    │ │ JS Scripts     │ │ Delegation     │ │ Pure Rust    │ │\n│  │ - install      │ │ - new          │ │ - dev          │ │ - help       │ │\n│  │ - add          │ │ - migrate      │ │ - build        │ │              │ │\n│  │ - remove       │ │ - --version    │ │ - test         │ │              │ │\n│  │ - update       │ │                │ │ - lint         │ │              │ │\n│  │ - ...          │ │                │ │ - ...          │ │              │ │\n│  └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ └──────────────┘ │\n│          │                  │                  │                           │\n└──────────┼──────────────────┼──────────────────┼───────────────────────────┘\n           │                  │                  │\n           ▼                  ▼                  ▼\n┌─────────────────────────────────────┐    ┌────────────────────────────────┐\n│    Flow 1: CLI Runtime              │    │    Flow 2: Project Runtime     │\n│    (Categories A & B)               │    │    (Category C)                │\n│                                     │    │                                │\n│  download_runtime_for_project(      │    │  download_runtime_for_project( │\n│    cli_package_json_dir             │    │    project_dir                 │\n│  )                                  │    │  )                             │\n│                                     │    │                                │\n│  vite_js_runtime reads:             │    │  vite_js_runtime reads:        │\n│  packages/global/package.json       │    │  <project>/package.json        │\n│  └─> devEngines.runtime: \"22.22.0\"  │    │  └─> devEngines.runtime        │\n│                                     │    │                                │\n└─────────────┬───────────────────────┘    └─────────────┬──────────────────┘\n              │                                          │\n              ▼                                          ▼\n┌─────────────────────────────────────────────────────────────────────────────┐\n│                          vite_js_runtime crate                              │\n│                                                                             │\n│  Built-in logic (same for both flows):                                      │\n│  1. Read package.json from provided path                                    │\n│  2. Extract devEngines.runtime.version                                      │\n│  3. Resolve semver range if needed                                          │\n│  4. Check cache (~/.vite-plus/js_runtime/node/{version}/)                   │\n│  5. Download Node.js if not cached                                          │\n│  6. Return JsRuntime with binary path                                       │\n│                                                                             │\n└─────────────────────────────────────────────────────────────────────────────┘\n              │                                          │\n              ▼                                          ▼\n┌─────────────────────────────────────┐    ┌────────────────────────────────┐\n│    Managed Node.js                  │    │    Managed Node.js             │\n│    (CLI's version: 22.22.0)         │    │    (Project's version)         │\n│                                     │    │                                │\n│  ┌─────────────┐  ┌──────────────┐  │    │  ┌──────────────────────────┐  │\n│  │ pnpm/npm/   │  │ Bundled      │  │    │  │ dist/index.js            │  │\n│  │ yarn        │  │ JS Scripts   │  │    │  │ → detects/installs local │  │\n│  │ (Cat. A)    │  │ (Cat. B)     │  │    │  │ → delegates to local CLI │  │\n│  └─────────────┘  └──────────────┘  │    │  └──────────────────────────┘  │\n└─────────────────────────────────────┘    └────────────────────────────────┘\n\nLegend:\n- Both flows use download_runtime_for_project(), just with different directory paths\n- vite_js_runtime handles all devEngines.runtime logic internally\n- Category C delegates through dist/index.js which handles local CLI detection\n- Category D: No Node.js required (pure Rust)\n```\n\n### JS Executor Module\n\nWhen JavaScript execution is needed, the executor uses `download_runtime_for_project()` with different directory paths:\n\n```rust\n// crates/vite_global_cli/src/js_executor.rs\n\nuse vite_js_runtime::download_runtime_for_project;\nuse std::process::Command;\n\npub struct JsExecutor {\n    cli_runtime: Option<JsRuntime>,      // Cached runtime for CLI commands\n    project_runtime: Option<JsRuntime>,  // Cached runtime for project delegation\n    scripts_dir: PathBuf,                // From VITE_GLOBAL_CLI_JS_SCRIPTS_DIR\n}\n\nimpl JsExecutor {\n    pub fn new(scripts_dir: PathBuf) -> Self {\n        Self {\n            cli_runtime: None,\n            project_runtime: None,\n            scripts_dir,\n        }\n    }\n\n    /// Get the CLI's package.json directory (parent of scripts_dir)\n    fn get_cli_package_dir(&self) -> PathBuf {\n        self.scripts_dir.parent().unwrap().to_path_buf()\n    }\n\n    /// Get runtime for CLI's own commands (Categories A & B)\n    /// Uses CLI's package.json devEngines.runtime (e.g., \"22.22.0\")\n    pub async fn ensure_cli_runtime(&mut self) -> Result<&JsRuntime, Error> {\n        if self.cli_runtime.is_none() {\n            // download_runtime_for_project reads devEngines.runtime from\n            // the package.json in the given directory\n            let cli_dir = self.get_cli_package_dir();\n            let runtime = download_runtime_for_project(&cli_dir).await?;\n            self.cli_runtime = Some(runtime);\n        }\n        Ok(self.cli_runtime.as_ref().unwrap())\n    }\n\n    /// Get runtime for project delegation (Category C)\n    /// Uses project's package.json devEngines.runtime\n    pub async fn ensure_project_runtime(&mut self, project_path: &Path) -> Result<&JsRuntime, Error> {\n        if self.project_runtime.is_none() {\n            // download_runtime_for_project reads devEngines.runtime from\n            // the project's package.json\n            let runtime = download_runtime_for_project(project_path).await?;\n            self.project_runtime = Some(runtime);\n        }\n        Ok(self.project_runtime.as_ref().unwrap())\n    }\n\n    /// Execute CLI's bundled JS script (Categories A & B)\n    pub async fn execute_cli_script(&mut self, script_name: &str, args: &[&str]) -> Result<ExitStatus, Error> {\n        let runtime = self.ensure_cli_runtime().await?;\n        let script_path = self.scripts_dir.join(script_name);\n        let status = Command::new(runtime.get_binary_path())\n            .arg(&script_path)\n            .args(args)\n            .status()?;\n        Ok(status)\n    }\n\n    /// Execute package manager command (Category A)\n    pub async fn execute_pm_command(&mut self, pm: &str, args: &[&str]) -> Result<ExitStatus, Error> {\n        let runtime = self.ensure_cli_runtime().await?;\n        // PM binaries are in the same bin directory as node\n        let pm_path = runtime.get_bin_prefix().join(pm);\n        let status = Command::new(runtime.get_binary_path())\n            .arg(&pm_path)\n            .args(args)\n            .status()?;\n        Ok(status)\n    }\n\n    /// Delegate to local vite-plus CLI (Category C)\n    ///\n    /// Passes the command through `dist/index.js` which handles:\n    /// - Detecting if vite-plus is installed locally\n    /// - Auto-installing if it's a dependency but not installed\n    /// - Prompting user to add it if not found\n    /// - Delegating to the local CLI's `dist/bin.js`\n    pub async fn delegate_to_local_cli(\n        &mut self,\n        project_path: &Path,\n        args: &[&str]\n    ) -> Result<ExitStatus, Error> {\n        // Use project's runtime version via download_runtime_for_project\n        let runtime = self.ensure_project_runtime(project_path).await?;\n\n        // Get the JS entry point (dist/index.js)\n        let entry_point = self.scripts_dir.join(\"index.js\");\n\n        // Execute dist/index.js with the command and args\n        // The JS layer handles detecting/installing local vite-plus\n        let status = Command::new(runtime.get_binary_path())\n            .arg(&entry_point)\n            .args(args)\n            .current_dir(project_path)\n            .status()?;\n        Ok(status)\n    }\n}\n```\n\n**Key points:**\n\n- Both flows use `download_runtime_for_project()` - the only difference is the directory path\n- `vite_js_runtime` handles all `devEngines.runtime` logic internally (reading package.json, resolving versions, caching)\n- CLI commands use CLI's package.json directory (e.g., `packages/global/`)\n- Project delegation uses project's directory and passes commands through `dist/index.js`\n- The JS entry point handles local CLI detection and delegation\n\n### Implementation Phases\n\n#### Phase 1: Foundation & All Package Manager Commands\n\n**Scope:**\n\n- Set up `vite_global_cli` crate structure\n- Implement CLI parsing with clap\n- Implement workspace detection (reuse from `vite_task`)\n- Implement package manager detection and wrapping\n- Implement ALL package manager commands:\n  - `install [packages]` / `i` - Install dependencies or add packages\n  - `add <packages>` - Add packages to dependencies\n  - `remove <packages>` / `rm`, `un`, `uninstall` - Remove packages\n  - `update [packages]` / `up` - Update packages\n  - `outdated [packages]` - Check for outdated packages\n  - `dedupe` - Deduplicate dependencies\n  - `why <package>` / `explain` - Explain why a package is installed\n  - `info <package>` / `view`, `show` - View package info from registry\n  - `link [package|dir]` / `ln` - Link packages\n  - `unlink [package|dir]` - Unlink packages\n  - `dlx <package>` - Execute package without installing\n  - `pm <subcommand>` - Forward to package manager (list, prune, pack)\n\n**Files to create:**\n\n- `crates/vite_global_cli/Cargo.toml`\n- `crates/vite_global_cli/src/main.rs`\n- `crates/vite_global_cli/src/cli.rs`\n- `crates/vite_global_cli/src/commands/mod.rs`\n- `crates/vite_global_cli/src/commands/add.rs` # Add packages (struct-based: AddCommand)\n- `crates/vite_global_cli/src/commands/install.rs` # Install dependencies (struct-based: InstallCommand)\n- `crates/vite_global_cli/src/commands/remove.rs` # Remove packages (struct-based: RemoveCommand)\n- `crates/vite_global_cli/src/commands/update.rs` # Update packages (struct-based: UpdateCommand)\n- `crates/vite_global_cli/src/commands/dedupe.rs` # Deduplicate deps (struct-based: DedupeCommand)\n- `crates/vite_global_cli/src/commands/outdated.rs` # Check outdated (struct-based: OutdatedCommand)\n- `crates/vite_global_cli/src/commands/why.rs` # Explain dependency (struct-based: WhyCommand)\n- `crates/vite_global_cli/src/commands/link.rs` # Link packages (struct-based: LinkCommand)\n- `crates/vite_global_cli/src/commands/unlink.rs` # Unlink packages (struct-based: UnlinkCommand)\n- `crates/vite_global_cli/src/commands/dlx.rs` # Execute package (struct-based: DlxCommand)\n- `crates/vite_global_cli/src/commands/pm.rs` # PM subcommands (prune, pack, list, etc.)\n- `crates/vite_global_cli/src/commands/new.rs` # Project scaffolding\n- `crates/vite_global_cli/src/commands/migrate.rs` # Migration command\n- `crates/vite_global_cli/src/commands/delegate.rs` # Local CLI delegation\n- `crates/vite_global_cli/src/commands/version.rs` # Version display\n- `crates/vite_global_cli/src/js_executor.rs`\n- `crates/vite_global_cli/src/error.rs`\n\n**Success Criteria:**\n\n- [x] All PM commands work without pre-installed Node.js (uses managed Node.js)\n- [x] Managed Node.js is downloaded automatically when first PM command runs\n- [x] Auto-detects pnpm/npm/yarn in the project\n- [x] Package manager is downloaded via managed Node.js if not available\n- [x] All PM commands work identically to current Node.js CLI\n- [x] `--help` documentation matches current CLI\n- [x] Command aliases work correctly (i, rm, up, etc.)\n\n#### Phase 2: Project Scaffolding\n\n**Scope:**\n\n- Implement `new` command for built-in templates (vite:monorepo, etc.)\n- Implement JS executor for remote templates\n- Integrate with `vite_js_runtime` for Node.js download\n\n**Success Criteria:**\n\n- [x] `vp create vite:monorepo` works without Node.js\n- [x] `vp create create-vite` downloads Node.js and executes correctly\n\n#### Phase 3: Migration & Remaining Commands\n\n**Scope:**\n\n- Implement `migrate` command\n- Implement local CLI delegation\n- Implement `--version` and help system\n\n**Success Criteria:**\n\n- [x] `vp migrate` works correctly\n- [x] Local commands delegate properly\n- [x] Full feature parity with Node.js CLI\n\n#### Phase 4: Distribution & Testing\n\n**Scope:**\n\n- Set up cross-platform builds (Linux, macOS, Windows)\n- Create installation scripts\n- Add to Homebrew, cargo install, etc.\n- Comprehensive testing\n\n**Success Criteria:**\n\n- [x] Binary available via multiple channels\n- [x] Installation scripts work on all platforms\n- [x] All snap tests pass\n\n### Dependency Changes\n\n**New dependencies for `vite_global_cli`:**\n\n```toml\n[dependencies]\nvite_js_runtime = { path = \"../vite_js_runtime\" }\nvite_shared = { path = \"../vite_shared\" }  # For cache dir, etc.\nvite_path = { path = \"../vite_path\" }\n\nclap = { version = \"4\", features = [\"derive\"] }\ntokio = { version = \"1\", features = [\"full\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nanyhow = \"1\"\nthiserror = \"1\"\n```\n\n### Configuration\n\nThe global CLI will use the same configuration locations as the current CLI:\n\n- **Home directory**: `~/.vite-plus/` (via `vite_shared::get_vite_plus_home`)\n- **Node.js runtime**: `~/.vite-plus/js_runtime/node/{version}/`\n- **Package manager**: Auto-detected from lockfile or package.json\n\n### JS Runtime Version Management\n\nThere are **two distinct runtime resolution strategies** based on the command category:\n\n#### Strategy 1: Global CLI Commands (Categories A & B)\n\nFor package manager commands, `new`, `migrate`, and `--version`, the runtime version comes from the **global CLI's own package.json** (`packages/global/package.json`):\n\n```json\n{\n  \"name\": \"vite-plus-cli\",\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"22.22.0\"\n    }\n  }\n}\n```\n\n**Rationale:**\n\n- These commands are part of the global CLI's functionality\n- They should use a consistent, tested Node.js version\n- The version can be updated with CLI releases\n- Users don't need a project to run `vp create` or `vp install`\n\n#### Strategy 2: Local CLI Delegation (Category C)\n\nFor commands delegated to local `vite-plus` (`dev`, `build`, `test`, `lint`, etc.), the runtime version comes from the **current project's package.json**:\n\n```json\n{\n  \"name\": \"my-project\",\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"^20.18.0\"\n    }\n  }\n}\n```\n\n**Resolution order for Category C:**\n\n1. Project's `devEngines.runtime` (if present)\n2. Fallback to CLI's default version (from `packages/global/package.json`)\n\n**Rationale:**\n\n- Projects may require specific Node.js versions for their builds\n- Team members need consistent runtime versions for reproducibility\n- Different projects can use different Node.js versions\n\n#### Summary Table\n\n| Command Category | Runtime Source                        | Example Commands             |\n| ---------------- | ------------------------------------- | ---------------------------- |\n| A: PM Commands   | CLI's package.json                    | install, add, remove, update |\n| B: JS Scripts    | CLI's package.json                    | new, migrate, --version      |\n| C: Delegation    | Project's package.json → CLI fallback | dev, build, test, lint       |\n| D: Pure Rust     | None                                  | help                         |\n\n**Benefits:**\n\n- **Separation of concerns**: CLI commands use CLI's runtime, project commands use project's runtime\n- **Per-project control**: Each project specifies its required runtime version for builds\n- **Team consistency**: All developers use the same runtime version for a project\n- **No system conflicts**: Different projects can use different Node.js versions\n- **Automatic provisioning**: Runtime is downloaded automatically if not cached\n\nThis integrates with the existing `vite_js_runtime` crate's capabilities (see [js-runtime RFC](./js-runtime.md)).\n\n### Packaging & Distribution Strategy\n\nSince `new` and `migrate` commands are still implemented via JS scripts, we need a hybrid distribution strategy that provides both the Rust binary and the JS scripts.\n\n#### Platform-Specific npm Packages\n\nCreate platform-specific npm packages containing only the native binary:\n\n| Package Name                               | Platform | Architecture          |\n| ------------------------------------------ | -------- | --------------------- |\n| `@voidzero-dev/vite-plus-cli-darwin-arm64` | macOS    | ARM64 (Apple Silicon) |\n| `@voidzero-dev/vite-plus-cli-darwin-x64`   | macOS    | Intel x64             |\n| `@voidzero-dev/vite-plus-cli-linux-arm64`  | Linux    | ARM64                 |\n| `@voidzero-dev/vite-plus-cli-linux-x64`    | Linux    | Intel x64             |\n| `@voidzero-dev/vite-plus-cli-win32-arm64`  | Windows  | ARM64                 |\n| `@voidzero-dev/vite-plus-cli-win32-x64`    | Windows  | Intel x64             |\n\n**Package structure:**\n\n```\n@voidzero-dev/vite-plus-cli-darwin-arm64/\n├── package.json\n└── vite                    # Native binary (no extension on Unix)\n\n@voidzero-dev/vite-plus-cli-win32-x64/\n├── package.json\n└── vite.exe                # Native binary (Windows)\n```\n\n**Platform package.json:**\n\n```json\n{\n  \"name\": \"@voidzero-dev/vite-plus-cli-darwin-arm64\",\n  \"version\": \"1.0.0\",\n  \"os\": [\"darwin\"],\n  \"cpu\": [\"arm64\"],\n  \"main\": \"vite\",\n  \"files\": [\"vite\"]\n}\n```\n\n#### Main npm Package (vite-plus-cli)\n\nThe main `vite-plus-cli` package uses `optionalDependencies` to install the correct platform binary:\n\n```json\n{\n  \"name\": \"vite-plus-cli\",\n  \"version\": \"1.0.0\",\n  \"bin\": {\n    \"vite\": \"./bin/vite\"\n  },\n  \"optionalDependencies\": {\n    \"@voidzero-dev/vite-plus-cli-darwin-arm64\": \"1.0.0\",\n    \"@voidzero-dev/vite-plus-cli-darwin-x64\": \"1.0.0\",\n    \"@voidzero-dev/vite-plus-cli-linux-arm64\": \"1.0.0\",\n    \"@voidzero-dev/vite-plus-cli-linux-x64\": \"1.0.0\",\n    \"@voidzero-dev/vite-plus-cli-win32-arm64\": \"1.0.0\",\n    \"@voidzero-dev/vite-plus-cli-win32-x64\": \"1.0.0\"\n  }\n}\n```\n\n**Binary resolution (`bin/vite`):**\n\nThe `bin/vite` script needs to be refactored to find and execute the Rust binary from `optionalDependencies`:\n\n```javascript\n#!/usr/bin/env node\n\nimport { execFileSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { createRequire } from 'node:module';\nimport { fileURLToPath } from 'node:url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst require = createRequire(import.meta.url);\n\n// Platform to package mapping\nconst PLATFORMS = {\n  'darwin-arm64': '@voidzero-dev/vite-plus-cli-darwin-arm64',\n  'darwin-x64': '@voidzero-dev/vite-plus-cli-darwin-x64',\n  'linux-arm64': '@voidzero-dev/vite-plus-cli-linux-arm64',\n  'linux-x64': '@voidzero-dev/vite-plus-cli-linux-x64',\n  'win32-arm64': '@voidzero-dev/vite-plus-cli-win32-arm64',\n  'win32-x64': '@voidzero-dev/vite-plus-cli-win32-x64',\n};\n\nfunction getBinaryPath() {\n  const binaryName = process.platform === 'win32' ? 'vp.exe' : 'vp';\n\n  // 1. First check for local binary in same directory (local development)\n  const localBinaryPath = join(__dirname, binaryName);\n  if (existsSync(localBinaryPath)) {\n    return localBinaryPath;\n  }\n\n  // 2. Find binary from platform-specific optionalDependency\n  const platform = `${process.platform}-${process.arch}`;\n  const packageName = PLATFORMS[platform];\n\n  if (!packageName) {\n    throw new Error(`Unsupported platform: ${platform}`);\n  }\n\n  // Try to find the binary in node_modules\n  const binaryPath = join(__dirname, '..', 'node_modules', packageName, binaryName);\n\n  if (existsSync(binaryPath)) {\n    return binaryPath;\n  }\n\n  // Fallback: try require.resolve\n  const packagePath = require.resolve(`${packageName}/package.json`);\n  return join(dirname(packagePath), binaryName);\n}\n\nconst binaryPath = getBinaryPath();\n// Set VITE_GLOBAL_CLI_JS_SCRIPTS_DIR to point to dist/index.js location\nconst jsScriptsDir = join(__dirname, '..');\n\nexecFileSync(binaryPath, process.argv.slice(2), {\n  stdio: 'inherit',\n  env: {\n    ...process.env,\n    VITE_GLOBAL_CLI_JS_SCRIPTS_DIR: jsScriptsDir,\n  },\n});\n```\n\n**How it works:**\n\n1. `bin/vite` finds the Rust binary (`vp`) from the platform-specific optional dependency\n2. Sets `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR` pointing to the package root (where `dist/index.js` is)\n3. Executes the Rust binary with all arguments\n4. The Rust binary uses the JS entry point at `$VITE_GLOBAL_CLI_JS_SCRIPTS_DIR/dist/index.js`\n\nThis ensures npm installation works the same way as standalone installation.\n\n#### Standalone Installation (install.sh)\n\nFor users who prefer standalone installation without npm:\n\n```bash\n#!/bin/bash\n# https://vite.plus\n#\n# Environment variables:\n#   VITE_PLUS_VERSION - Version to install (default: latest)\n#   VITE_PLUS_INSTALL_DIR - Installation directory (default: ~/.vite-plus)\n#   NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org)\n\nset -e\n\nVITE_PLUS_VERSION=\"${VITE_PLUS_VERSION:-latest}\"\nINSTALL_DIR=\"${VITE_PLUS_INSTALL_DIR:-$HOME/.vite-plus}\"\nNPM_REGISTRY=\"${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}\"\nNPM_REGISTRY=\"${NPM_REGISTRY%/}\"\n\n# Detect platform and get version...\n# (platform detection code omitted for brevity)\n\n# Set up version-specific directories\nVERSION_DIR=\"$INSTALL_DIR/$VITE_PLUS_VERSION\"\nBIN_DIR=\"$VERSION_DIR/bin\"\nDIST_DIR=\"$VERSION_DIR/dist\"\nCURRENT_LINK=\"$INSTALL_DIR/current\"\n\n# Create directories\nmkdir -p \"$BIN_DIR\" \"$DIST_DIR\"\n\n# Download platform package (binary + .node files)\nplatform_url=\"${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${package_suffix}-${VITE_PLUS_VERSION}.tgz\"\n# Extract to temp dir, copy binary to BIN_DIR, copy .node files to DIST_DIR\n\n# Download main package (JS scripts + package.json)\nmain_url=\"${NPM_REGISTRY}/vite-plus-cli/-/vite-plus-cli-${VITE_PLUS_VERSION}.tgz\"\n# Extract dist/* to DIST_DIR, copy package.json to VERSION_DIR\n\n# Create/update current symlink\nln -sfn \"$VITE_PLUS_VERSION\" \"$CURRENT_LINK\"\n\n# Cleanup old versions (keep max 5)\ncleanup_old_versions\n\n# Add ~/.vite-plus/current/bin to PATH\n# (shell profile update code omitted for brevity)\n```\n\nSee [`packages/global/install.sh`](../packages/global/install.sh) for the full implementation.\n\n#### Windows Installation (install.ps1)\n\nFor Windows users, provide a PowerShell script:\n\n```powershell\n# https://vite.plus/ps1\n#\n# Environment variables:\n#   VITE_PLUS_VERSION - Version to install (default: latest)\n#   VITE_PLUS_INSTALL_DIR - Installation directory (default: $env:USERPROFILE\\.vite-plus)\n#   NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org)\n\n$ErrorActionPreference = \"Stop\"\n\n$ViteVersion = if ($env:VITE_PLUS_VERSION) { $env:VITE_PLUS_VERSION } else { \"latest\" }\n$InstallDir = if ($env:VITE_PLUS_INSTALL_DIR) { $env:VITE_PLUS_INSTALL_DIR } else { \"$env:USERPROFILE\\.vite-plus\" }\n$NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { \"https://registry.npmjs.org\" }\n\n# Detect architecture and get version...\n# (detection code omitted for brevity)\n\n# Set up version-specific directories\n$VersionDir = \"$InstallDir\\$ViteVersion\"\n$BinDir = \"$VersionDir\\bin\"\n$DistDir = \"$VersionDir\\dist\"\n$CurrentLink = \"$InstallDir\\current\"\n\n# Create directories\nNew-Item -ItemType Directory -Force -Path $BinDir | Out-Null\nNew-Item -ItemType Directory -Force -Path $DistDir | Out-Null\n\n# Download platform package (binary + .node files)\n# Extract binary to BinDir, .node files to DistDir\n\n# Download main package (JS scripts + package.json)\n# Extract dist/* to DistDir, package.json to VersionDir\n\n# Create/update current junction (Windows symlink equivalent)\nif (Test-Path $CurrentLink) {\n    cmd /c rmdir \"$CurrentLink\" 2>$null\n}\ncmd /c mklink /J \"$CurrentLink\" \"$VersionDir\" | Out-Null\n\n# Cleanup old versions (keep max 5)\nCleanup-OldVersions -InstallDir $InstallDir\n\n# Add $InstallDir\\current\\bin to user PATH\n```\n\nSee [`packages/global/install.ps1`](../packages/global/install.ps1) for the full implementation.\n\n**Windows installation options:**\n\n1. **PowerShell one-liner:**\n\n   ```powershell\n   irm https://vite.plus/ps1 | iex\n   ```\n\n2. **npm (if Node.js is available):**\n\n   ```cmd\n   npm install -g vite-plus-cli\n   ```\n\n3. **Scoop (future):**\n   ```cmd\n   scoop install vite-plus\n   ```\n\n#### Directory Layout for Standalone Installation\n\nThe installer supports multiple versions with symlinks, allowing version switching without PATH changes:\n\n```\n~/.vite-plus/\n├── current -> 0.0.0-abc123     # Symlink to active version\n├── 0.0.0-abc123/               # Version directory\n│   ├── bin/\n│   │   └── vp                  # Native Rust binary\n│   ├── dist/\n│   │   ├── index.js            # Bundled JS entry point\n│   │   └── *.node              # NAPI bindings\n│   └── package.json            # For devEngines.runtime configuration\n├── 0.0.0-def456/               # Another version\n│   └── ...\n└── ...\n```\n\n**Key features:**\n\n- PATH points to `~/.vite-plus/current/bin` (stable location)\n- Installing a new version updates the `current` symlink\n- Old versions are automatically cleaned up (keeps max 5 versions)\n\n#### How the Rust Binary Uses JS Scripts\n\nWhen the Rust binary needs to execute JS (for `new`, `migrate`, `--version`, or PM commands):\n\n1. Check `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR` environment variable (optional)\n2. If not set, auto-detect by looking for `dist/index.js` relative to the binary\n3. Download Node.js via `vite_js_runtime` if not cached (version from `package.json` devEngines.runtime)\n4. Execute the JS entry point with managed Node.js, passing command and arguments\n\n**Auto-detection logic:**\n\n- For npm installation: binary is in `node_modules/vite-plus-cli/bin/`, JS entry point is `node_modules/vite-plus-cli/dist/index.js`\n- For standalone installation: binary is in `~/.vite-plus/current/bin/`, JS entry point is `~/.vite-plus/current/dist/index.js`\n- For local development: binary is in `packages/global/bin/`, JS entry point is `packages/global/dist/index.js`\n\n**Standalone installation contents:**\n\n- `bin/vp` - Native Rust binary\n- `dist/index.js` - Bundled JS entry point\n- `dist/*.node` - NAPI bindings for JS scripts\n- `package.json` - Contains devEngines.runtime configuration\n\n```rust\n// In the Rust binary\nfn get_js_scripts_dir() -> Result<PathBuf, Error> {\n    // 1. Check environment variable first\n    if let Ok(dir) = std::env::var(\"VITE_GLOBAL_CLI_JS_SCRIPTS_DIR\") {\n        return Ok(PathBuf::from(dir));\n    }\n\n    // 2. Auto-detect based on binary location\n    // Binary is at ~/.vite-plus/current/bin/vp\n    // Scripts are at ~/.vite-plus/current/dist/\n    let exe_path = std::env::current_exe()?;\n    let exe_dir = exe_path.parent().ok_or(Error::JsEntryPointNotFound)?;\n\n    // JS scripts dir is always at ../dist/ relative to bin/\n    let scripts_dir = exe_dir.join(\"../dist\");\n\n    if scripts_dir.exists() {\n        return Ok(scripts_dir.canonicalize()?);\n    }\n\n    Err(Error::JsEntryPointNotFound)\n}\n\nasync fn run_js_command(&self, command: &str, args: &[&str]) -> Result<(), Error> {\n    let scripts_dir = get_js_scripts_dir()?;\n    let entry_point = scripts_dir.join(\"index.js\");\n\n    // Ensure Node.js is available (version from package.json devEngines.runtime)\n    let runtime = self.js_executor.ensure_cli_runtime().await?;\n\n    // Execute JS entry point with command and arguments\n    // The JS entry point handles routing to the appropriate handler\n    let status = Command::new(runtime.get_binary_path())\n        .arg(&entry_point)\n        .arg(command)  // e.g., \"new\", \"migrate\", \"--version\"\n        .args(args)\n        .status()?;\n\n    Ok(())\n}\n```\n\n#### Build & Publish Workflow\n\nThe existing `packages/global/publish-native-addons.ts` script already publishes platform-specific packages via `@napi-rs/cli`. We only need to modify it to also include the Rust binary.\n\n**Current artifact structure** (see [@voidzero-dev/vite-plus-cli-darwin-arm64 on unpkg](https://app.unpkg.com/@voidzero-dev/vite-plus-cli-darwin-arm64)):\n\n```\n@voidzero-dev/vite-plus-cli-darwin-arm64/\n├── package.json\n├── vite-plus-cli.darwin-arm64.node  # NAPI binding (existing)\n└── vp                                # Rust binary (to be added)\n```\n\n**Changes to `publish-native-addons.ts`:**\n\n1. Before publishing, copy the compiled Rust binary to each platform's directory\n2. Add the binary to the package's `files` array\n3. Publish as usual\n\n```typescript\n// packages/global/publish-native-addons.ts\n\n// ... existing code ...\n\n// NEW: Copy Rust binary to platform package before publishing\nconst rustBinaryName = platform === 'win32' ? 'vp.exe' : 'vp';\nconst rustBinarySource = `../../target/${rustTarget}/release/${rustBinaryName}`;\nconst rustBinaryDest = `npm/${platform}-${arch}/${rustBinaryName}`;\n\nif (fs.existsSync(rustBinarySource)) {\n  fs.copyFileSync(rustBinarySource, rustBinaryDest);\n  console.log(`Copied Rust binary to ${rustBinaryDest}`);\n}\n\n// ... existing publish code ...\n```\n\n**Rust binary targets:**\n\n| Platform Package | Rust Target                 |\n| ---------------- | --------------------------- |\n| darwin-arm64     | `aarch64-apple-darwin`      |\n| darwin-x64       | `x86_64-apple-darwin`       |\n| linux-arm64      | `aarch64-unknown-linux-gnu` |\n| linux-x64        | `x86_64-unknown-linux-gnu`  |\n| win32-arm64      | `aarch64-pc-windows-msvc`   |\n| win32-x64        | `x86_64-pc-windows-msvc`    |\n\n**CI/CD Integration:**\n\nThe existing CI workflow builds NAPI bindings for all platforms. We add a step to also build the Rust binary:\n\n```yaml\n# In existing CI workflow\n- name: Build Rust CLI\n  run: cargo build --release --target ${{ matrix.target }} -p vite_global_cli\n```\n\n### Error Handling\n\n```rust\n#[derive(Debug, thiserror::Error)]\npub enum Error {\n    #[error(\"No package manager detected. Please run in a project directory.\")]\n    NoPackageManager,\n\n    #[error(\"Failed to download Node.js runtime: {0}\")]\n    RuntimeDownload(#[from] vite_js_runtime::Error),\n\n    #[error(\"Command execution failed: {0}\")]\n    CommandExecution(std::io::Error),\n\n    // ... more variants\n}\n```\n\n**Note:** Local CLI detection errors are handled by the JS layer (`dist/index.js`), which provides user-friendly messages.\n\n### Local Development\n\nDuring local development, the Rust binary needs to be available alongside the JS scripts in `packages/global/`.\n\n**Installation script:**\n\nThe script `packages/tools/src/install-global-cli.ts` handles copying the compiled Rust binary to the correct location:\n\n```\npackages/global/\n├── bin/\n│   └── vp              # Rust binary copied here by install-global-cli.ts\n├── src/\n│   ├── new/\n│   ├── migration/\n│   ├── version.ts\n│   └── ...\n└── package.json        # Contains devEngines.runtime: \"22.22.0\"\n```\n\n**Development workflow:**\n\n1. Build the Rust binary: `cargo build -p vite_global_cli`\n2. Build JS: `pnpm -F vite-plus-cli build`\n3. Run install script: `pnpm bootstrap-cli` (which internally runs `install-global-cli.ts`)\n4. The script copies the binary to `packages/global/bin/vp`\n5. Local development and snap tests work unchanged\n\n**Directory structure after setup:**\n\n```\npackages/global/\n├── bin/\n│   └── vp              # Rust binary copied here\n├── dist/\n│   └── index.js        # Bundled JS entry point\n└── package.json        # Contains devEngines.runtime: \"22.22.0\"\n```\n\n**Benefits:**\n\n- Consistent experience with production\n- Snap tests run against the actual Rust binary\n- Auto-detection finds `dist/index.js` relative to binary location\n- No wrapper scripts or environment variables needed\n\n### Testing Strategy\n\n**Unit Tests:**\n\n- CLI argument parsing\n- Workspace detection\n- Command routing\n\n**Integration Tests:**\n\n- Full command execution in test fixtures\n- Cross-platform behavior\n- JS executor with real Node.js download\n\n**Snap Tests:**\n\n- Reuse existing snap test infrastructure\n- Add new tests for Rust binary behavior\n- Tests run against the Rust binary in `packages/global/bin/vp`\n\n```rust\n#[test]\nfn test_install_command_parsing() {\n    let args = cli::parse(&[\"vite\", \"install\", \"lodash\", \"--save-dev\"]);\n    assert!(matches!(args.command, Command::Install { .. }));\n}\n\n#[tokio::test]\nasync fn test_js_executor_downloads_node() {\n    let mut executor = JsExecutor::new();\n    let runtime = executor.ensure_runtime().await.unwrap();\n    assert!(runtime.get_binary_path().exists());\n}\n```\n\n## Design Decisions\n\n### 1. Why Node.js 22.22.0 as Default?\n\nNode.js 22 is the current LTS line with long-term support. Version 22.22.0 is chosen as a stable point release.\n\n**Configuration approach:**\n\n- Default version is configured in `packages/global/package.json` via `devEngines.runtime`\n- Can be updated in future releases without rebuilding the Rust binary\n- Projects can override via their own `devEngines.runtime` configuration\n\n**Version resolution priority:**\n\n1. Project's `devEngines.runtime` (if present)\n2. CLI's default from bundled `package.json`\n\n### 2. Why Not Bundle Node.js?\n\nBundling Node.js would significantly increase binary size (~100MB+). Instead, downloading on-demand:\n\n- Keeps initial download small (~20MB)\n- Allows version flexibility\n- Leverages existing `vite_js_runtime` caching\n\n### 3. Why Wrap Package Managers Instead of Reimplementing?\n\nReimplementing pnpm/npm/yarn would be a massive undertaking with subtle compatibility issues. Wrapping existing package managers:\n\n- Ensures compatibility\n- Reduces maintenance burden\n- Allows users to use their preferred PM\n\n### 4. Why Keep NAPI Bindings?\n\nThe NAPI bindings serve the local CLI (`vite-plus` package) use case where Node.js is already available. This allows the same Rust code to be used in both:\n\n- Standalone binary (for global CLI)\n- Node.js addon (for local CLI performance)\n\n### 5. Why Platform-Specific npm Packages?\n\nThis approach (used by esbuild, swc, rolldown, etc.) provides several benefits:\n\n- **npm compatibility**: Users can still `npm install -g vite-plus-cli`\n- **Automatic platform detection**: npm handles installing the correct binary\n- **Dual-use distribution**: Same binaries work for both npm and standalone installation\n- **No binary in main package**: Main package stays small, only platform-specific binaries are downloaded\n- **CDN distribution**: Unpkg/jsdelivr can serve binaries directly\n\n### 6. Why Keep JS Scripts for `new` and `migrate`?\n\nThese commands involve:\n\n- Complex template rendering with user prompts (@clack/prompts)\n- Remote template downloads and execution (create-vite, etc.)\n- Code transformation rules that may change frequently\n- Integration with the existing vite-plus ecosystem\n\nRewriting these in Rust would be significant effort with limited benefit. Instead:\n\n- JS scripts continue to work as-is\n- Rust binary invokes them via managed Node.js runtime\n- Updates to templates/migrations don't require binary rebuilds\n\n## Migration Path\n\n### For Existing Users\n\n1. Users with `vite-plus-cli` via npm continue to work\n2. New installation methods become available (brew, curl, cargo)\n3. Eventual deprecation of npm-based global CLI (with ample warning period)\n\n### For CI/CD\n\n```yaml\n# Before\n- run: npm install -g vite-plus-cli\n\n# After (recommended)\n- run: curl -fsSL https://vite.plus | bash\n# or\n- uses: voidzero-dev/setup-vite-plus-action@v1\n```\n\n## Future Enhancements\n\n- [ ] Support Bun/Deno as alternative JS runtimes\n- [ ] Self-update command (`vp upgrade`)\n- [ ] Plugin system for custom commands\n- [ ] Shell completions generation\n- [ ] Offline mode with cached templates\n\n## Success Criteria\n\n1. [x] Binary runs on Linux, macOS, and Windows without pre-installed Node.js\n2. [x] Managed Node.js is downloaded automatically when needed (PM commands, new, migrate)\n3. [x] All current commands work identically to the existing Node.js CLI\n4. [x] Cold start time < 100ms (excluding Node.js/PM download)\n5. [x] Binary size < 30MB\n6. [x] Existing snap tests pass\n7. [x] Platform-specific npm packages published and installable\n8. [x] `npm install -g vite-plus-cli` works on all supported platforms\n9. [x] Standalone installation via `curl | bash` works\n10. [x] JS scripts for `new` and `migrate` correctly bundled and executed\n\n## References\n\n- [vite_js_runtime RFC](./js-runtime.md)\n- [split-global-cli RFC](./split-global-cli.md)\n- [install-command RFC](./install-command.md)\n- [Node.js Releases](https://nodejs.org/en/about/releases/)\n"
  },
  {
    "path": "rfcs/implode-command.md",
    "content": "# RFC: Implode (Self-Uninstall) Command\n\n## Status\n\nImplemented\n\n## Background\n\nVite+ currently has no built-in way to uninstall itself. Users must manually delete `~/.vite-plus/` and hunt through shell profiles (`.zshrc`, `.bashrc`, `.profile`, `config.fish`, etc.) to remove the sourcing lines added by `install.sh`. This is error-prone and leaves artifacts behind.\n\nA native `vp implode` command cleanly removes all Vite+ artifacts from the system in a single step.\n\n### What the Install Script Writes\n\nThe `install.sh` script adds the following block to shell profiles:\n\n```\n<blank line>\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n```\n\nFor fish shell:\n\n```\n<blank line>\n# Vite+ bin (https://viteplus.dev)\nsource \"$HOME/.vite-plus/env.fish\"\n```\n\nOn Windows, `install.ps1` adds `~/.vite-plus/bin` to the User PATH environment variable.\n\n## Goals\n\n1. Provide a single command to completely remove Vite+ from the system\n2. Clean up shell profiles (remove sourcing lines and associated comments)\n3. Remove the `~/.vite-plus/` directory and all its contents\n4. Handle Windows-specific cleanup (User PATH, locked binary)\n5. Require explicit confirmation to prevent accidental uninstalls\n\n## Non-Goals\n\n1. Selective removal (e.g., keeping downloaded Node.js versions)\n2. Backup before removal\n3. Removing project-level `vite-plus` npm dependencies\n\n## User Stories\n\n### Story 1: Interactive Uninstall\n\n```bash\n$ vp implode\nwarn: This will completely remove vite-plus from your system!\n\n  Directory: /home/user/.vite-plus\n  Shell profiles to clean:\n    - ~/.zshenv\n    - ~/.bashrc\n\nType uninstall to confirm:\nuninstall\n✓ Cleaned ~/.zshenv\n✓ Cleaned ~/.bashrc\n✓ Removed /home/user/.vite-plus\n\n✓ vite-plus has been removed from your system.\nnote: Restart your terminal to apply shell changes.\n```\n\n### Story 2: Non-Interactive Uninstall (CI)\n\n```bash\n$ vp implode --yes\n✓ Cleaned ~/.zshenv\n✓ Cleaned ~/.bashrc\n✓ Removed /home/user/.vite-plus\n\n✓ vite-plus has been removed from your system.\nnote: Restart your terminal to apply shell changes.\n```\n\n### Story 3: Not Installed\n\n```bash\n$ vp implode --yes\ninfo: vite-plus is not installed (directory does not exist)\n```\n\n### Story 4: Non-TTY Without --yes\n\n```bash\n$ echo \"\" | vp implode\nCannot prompt for confirmation: stdin is not a TTY. Use --yes to skip confirmation.\n```\n\n## Technical Design\n\n### Command Interface\n\n```\nvp implode [OPTIONS]\n\nOptions:\n  -y, --yes   Skip confirmation prompt\n  -h, --help  Print help\n```\n\n### Command Name: `implode`\n\n**Decision**: Use `implode` following mise's convention for a self-destruct command.\n\n**Alternatives considered**:\n\n- `self uninstall` / `self remove` — used by rustup (`rustup self uninstall`); requires subcommand group\n- `uninstall` — ambiguous with package uninstall operations\n\n**Rationale**:\n\n- Single word, memorable, unambiguous\n- Follows mise precedent (`mise implode`)\n- Cannot be confused with package management operations\n\n### Implementation Flow\n\n```\n┌───────────────────────────────────────────────┐\n│                vp implode                     │\n├───────────────────────────────────────────────┤\n│  1. Resolve ~/.vite-plus via get_vite_plus_home│\n│  2. Scan shell profiles for Vite+ lines       │\n│  3. Confirmation prompt (unless --yes)        │\n│  4. Clean shell profiles                      │\n│  5. Remove Windows PATH entry (Windows only)  │\n│  6. Remove ~/.vite-plus/ directory            │\n│  7. Print success message                     │\n└───────────────────────────────────────────────┘\n```\n\n#### Step 1: Resolve Home Directory\n\nUse `vite_shared::get_vite_plus_home()` to determine the install directory. If it doesn't exist, print \"not installed\" and exit 0.\n\n#### Step 2: Scan Shell Profiles\n\nCheck the following files for Vite+ sourcing lines:\n\n| Shell | Files                                        |\n| ----- | -------------------------------------------- |\n| zsh   | `~/.zshenv`, `~/.zshrc`                      |\n| bash  | `~/.bash_profile`, `~/.bashrc`, `~/.profile` |\n| fish  | `~/.config/fish/config.fish`                 |\n\n**POSIX detection pattern**: Lines containing `.vite-plus/env\"` (trailing quote avoids matching `env.fish`).\n\n**Fish detection pattern**: Lines containing `.vite-plus/env.fish`.\n\n#### Step 3: Confirmation\n\nUnless `--yes` is passed:\n\n- If stdin is not a TTY, return an error asking for `--yes`\n- Display what will be removed (directory path + affected shell profiles)\n- Require the user to type `uninstall` to confirm (similar to `rustup self uninstall`)\n\n#### Step 4: Shell Profile Cleanup\n\nFor each affected file, remove:\n\n1. The sourcing line (`. \"$HOME/.vite-plus/env\"` or `source ... env.fish`)\n2. The comment line above it (`# Vite+ bin (https://viteplus.dev)`)\n3. The blank line before the comment (added by the install script)\n\nShell profile cleanup is non-fatal — if a file can't be written, a warning is printed and the process continues.\n\n#### Step 5: Windows PATH Cleanup\n\nOn Windows, run PowerShell to remove `.vite-plus\\bin` from the User PATH environment variable:\n\n```powershell\n[Environment]::SetEnvironmentVariable('Path',\n  ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' |\n  Where-Object { $_ -ne '<bin_path>' }) -join ';', 'User')\n```\n\n#### Step 6: Remove Directory\n\n**Unix**: `std::fs::remove_dir_all` works even while the binary is running (Unix doesn't delete open files until all file descriptors are closed).\n\n**Windows**: The running `vp.exe` is always locked by the OS. Strategy:\n\n1. Rename `~/.vite-plus` to `~/.vite-plus.removing-<pid>` so the original path is immediately free for reinstall\n2. Spawn a detached `cmd.exe` process that retries `rmdir /S /Q` up to 10 times with 1-second pauses (via `timeout /T 1 /NOBREAK`), exiting as soon as the directory is gone\n\n### File Structure\n\n```\ncrates/vite_global_cli/\n├── src/\n│   ├── commands/\n│   │   ├── implode.rs        # Full implementation\n│   │   ├── mod.rs            # Add implode module\n│   │   └── ...\n│   └── cli.rs                # Add Implode command variant\n```\n\n### Error Handling\n\n| Error                        | Behavior                      |\n| ---------------------------- | ----------------------------- |\n| Home dir not found           | Print \"not installed\", exit 0 |\n| Home dir doesn't exist       | Print \"not installed\", exit 0 |\n| Can't determine user home    | Return error                  |\n| Shell profile write failure  | Warn and continue             |\n| Windows PATH cleanup failure | Warn and continue             |\n| Directory removal failure    | Return error                  |\n| Non-TTY without --yes        | Return error with suggestion  |\n\n## Testing Strategy\n\n### Unit Tests\n\n- `test_remove_vite_plus_lines_posix` — strips comment + sourcing from mock `.zshrc`\n- `test_remove_vite_plus_lines_fish` — strips fish `source` syntax\n- `test_remove_vite_plus_lines_no_match` — no modification when no Vite+ lines present\n- `test_remove_vite_plus_lines_absolute_path` — handles `/home/user/.vite-plus/env` variant\n- `test_remove_vite_plus_lines_preserves_surrounding` — other content untouched\n- `test_clean_shell_profile_integration` — tempdir-based integration test\n- `test_execute_not_installed` — points `VITE_PLUS_HOME` at non-existent path, verifies success\n\n### CI Tests\n\nImplode tests run in `.github/workflows/ci.yml` alongside the upgrade tests, across all platforms (bash on all, powershell and cmd on Windows):\n\n1. Run `vp implode --yes`\n2. Verify `~/.vite-plus/` is removed\n3. Reinstall via `pnpm bootstrap-cli:ci`\n4. Verify reinstallation works (`vp --version`)\n\n### Manual Testing\n\n```bash\n# Build and install\npnpm bootstrap-cli\n\n# Test interactive confirmation (cancel)\nvp implode\n\n# Test full uninstall\nvp implode --yes\n\n# Verify cleanup\nls ~/.vite-plus      # should not exist\ngrep vite-plus ~/.zshenv ~/.zshrc ~/.bashrc  # should find nothing\n\n# Verify vp is gone\nwhich vp             # should not be found (after terminal restart)\n```\n\n## References\n\n- [RFC: Upgrade Command](./upgrade-command.md)\n- [RFC: Global CLI (Rust Binary)](./global-cli-rust-binary.md)\n- [Install Script](../packages/cli/install.sh)\n- [Install Script (Windows)](../packages/cli/install.ps1)\n"
  },
  {
    "path": "rfcs/init-editor-configs.md",
    "content": "# RFC: Init Editor Configs\n\n## Summary\n\nAdd editor configuration file generation (starting with VSCode) to `vp create` and `vp migrate` flows.\n\nThis follows the same pattern as agent instructions (`--agent` / `--no-agent`), providing `--editor` / `--no-editor` options.\n\n## Motivation\n\nIDE configs such as VSCode require complicated JSON that users have to manually set up.\nSince Vite+ uses Oxc as its formatter, projects benefit from having:\n\n- `.vscode/settings.json` — Oxc as default formatter, format on save, etc.\n- `.vscode/extensions.json` — Recommended extensions (oxc-vscode)\n\nCurrently, users must create these files manually.\nVite+ should generate them automatically during project creation and migration, just like it already does for agent instructions.\n\n## Command Syntax\n\n```bash\n# Create with editor config\nvp create vite:application --editor vscode\n\n# Migrate with editor config\nvp migrate --editor vscode\n\n# Skip editor config (migrate only)\nvp migrate --no-editor\n```\n\nIn interactive mode, users are prompted to select their editor (None / VSCode) after the agent selection prompt.\n\n## Generated Files\n\n### `.vscode/settings.json`\n\nBased on [oxc-vscode's own `.vscode/settings.json`](https://github.com/oxc-project/oxc-vscode/blob/main/.vscode/settings.json).\n\n```json\n{\n  \"editor.defaultFormatter\": \"oxc.oxc-vscode\",\n  \"editor.formatOnSave\": true,\n  \"editor.formatOnSaveMode\": \"file\",\n  \"editor.codeActionsOnSave\": {\n    \"source.fixAll.oxc\": \"explicit\"\n  },\n  \"oxc.typeAware\": true\n}\n```\n\n### `.vscode/extensions.json`\n\n```json\n{\n  \"recommendations\": [\"VoidZero.vite-plus-extension-pack\"]\n}\n```\n\n## Behavior\n\n### Existing file handling\n\nWhen a config file already exists:\n\n- **Interactive mode**: Prompt with Merge / Skip options\n  - Merge: Add new keys without overwriting existing user settings. For `extensions.json`, deduplicate recommendations array.\n  - Skip: Leave unchanged\n- **Non-interactive mode**: Merge automatically (safe because existing keys are never overwritten)\n\n### Non-interactive defaults\n\n- `--editor vscode`: Write configs\n- `--no-editor`: Skip\n- Neither specified: Skip (conservative default)\n\n## Implementation Architecture\n\n### New file: `packages/cli/src/utils/editor.ts`\n\nMirrors `packages/cli/src/utils/agent.ts` structure:\n\n| agent.ts                          | editor.ts                |\n| --------------------------------- | ------------------------ |\n| `AGENTS` array                    | `EDITORS` array          |\n| `selectAgentTargetPath()`         | `selectEditor()`         |\n| `detectExistingAgentTargetPath()` | `detectExistingEditor()` |\n| `writeAgentInstructions()`        | `writeEditorConfigs()`   |\n\nKey difference from agent.ts: Uses JSON merge (via `utils/json.ts`) instead of file copy/append, since IDE configs are structured JSON.\n\n### Integration into `create/bin.ts`\n\n- Add `editor?: string` to `Options` interface\n- Add `'editor'` to mri `string` array\n- Add `--editor NAME` to help text\n- Call `selectEditor()` and `writeEditorConfigs()` after agent instructions at each write site (monorepo path ~L535, single project path ~L588)\n\n### Integration into `migration/bin.ts`\n\n- Add `editor?: string | false` to `MigrationOptions` interface\n- Add `--editor NAME` and `--no-editor` to help text\n- Call `selectEditor()` and `writeEditorConfigs()` after agent instructions (~L225)\n\n### Merge strategy\n\n- `settings.json`: 2-level deep merge. Existing keys preserved, new keys added. Nested objects (e.g., `[typescript]`) also merged with existing keys preserved.\n- `extensions.json`: `recommendations` array union with deduplication.\n\n### Key files to modify\n\n1. `packages/cli/src/utils/editor.ts` — New file, core logic\n2. `packages/cli/src/create/bin.ts` — Add option and integration\n3. `packages/cli/src/migration/bin.ts` — Add option and integration\n\n### Reused utilities\n\n- `packages/cli/src/utils/json.ts` — `readJsonFile`, `writeJsonFile`\n- `@voidzero-dev/vite-plus-prompts` — `select`, `isCancel`, `log`\n\n## Extensibility\n\nThe `EDITORS` array is designed to support additional editors in the future:\n\n```typescript\nexport const EDITORS = [\n  {\n    id: 'vscode',\n    label: 'VSCode',\n    targetDir: '.vscode',\n    files: ['settings.json', 'extensions.json'],\n  },\n  // Future: { id: 'jetbrains', label: 'JetBrains', targetDir: '.idea', files: [...] },\n] as const;\n```\n\n## Snap Tests\n\nExisting help-related snap tests will update automatically when help text changes. Dedicated snap tests can be added for:\n\n- `migration-editor-vscode` — Verify `.vscode/` generation during migration\n- `migration-no-editor` — Verify `--no-editor` skips generation\n"
  },
  {
    "path": "rfcs/install-command.md",
    "content": "# RFC: Vite+ Install Command\n\n## Summary\n\nAdd `vp install` command (alias: `vp i`) that automatically adapts to the detected package manager (pnpm/yarn/npm) for installing all dependencies in a project, with support for common flags and workspace-aware operations based on pnpm's API design.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands:\n\n```bash\npnpm install\nyarn install\nnpm install\n```\n\nThis creates friction in monorepo workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify workflows**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm install --frozen-lockfile  # pnpm project\nyarn install --frozen-lockfile  # yarn project (v1) or --immutable (v2+)\nnpm ci                          # npm project (clean install)\n\n# Different flags for production install\npnpm install --prod\nyarn install --production\nnpm install --omit=dev\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp install\nvp i\n\n# With flags\nvp install --frozen-lockfile\nvp install --prod\nvp install --ignore-scripts\n\n# Workspace operations\nvp install --filter app\n```\n\n### Command Syntax\n\n```bash\nvp install [OPTIONS]\nvp i [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Install all dependencies\nvp install\nvp i\n\n# Production install (no devDependencies)\nvp install --prod\nvp install -P\n\n# Frozen lockfile (CI mode)\nvp install --frozen-lockfile\n\n# Prefer offline (use cache when available)\nvp install --prefer-offline\n\n# Force reinstall\nvp install --force\n\n# Ignore scripts\nvp install --ignore-scripts\n\n# Workspace operations\nvp install --filter app              # Install for specific package\n```\n\n### Command Options\n\n| Option                 | Short | Description                                              |\n| ---------------------- | ----- | -------------------------------------------------------- |\n| `--prod`               | `-P`  | Do not install devDependencies                           |\n| `--dev`                | `-D`  | Only install devDependencies                             |\n| `--no-optional`        |       | Do not install optionalDependencies                      |\n| `--frozen-lockfile`    |       | Fail if lockfile needs to be updated                     |\n| `--no-frozen-lockfile` |       | Allow lockfile updates (opposite of --frozen-lockfile)   |\n| `--lockfile-only`      |       | Only update lockfile, don't install                      |\n| `--prefer-offline`     |       | Use cached packages when available                       |\n| `--offline`            |       | Only use packages already in cache                       |\n| `--force`              | `-f`  | Force reinstall all dependencies                         |\n| `--ignore-scripts`     |       | Do not run lifecycle scripts                             |\n| `--no-lockfile`        |       | Don't read or generate lockfile                          |\n| `--fix-lockfile`       |       | Fix broken lockfile entries                              |\n| `--shamefully-hoist`   |       | Create flat node_modules (pnpm)                          |\n| `--resolution-only`    |       | Re-run resolution for peer dependency analysis           |\n| `--silent`             |       | Suppress output (silent mode)                            |\n| `--filter <pattern>`   |       | Filter packages in monorepo                              |\n| `--workspace-root`     | `-w`  | Install in workspace root only                           |\n| `--save-exact`         | `-E`  | Save exact version (only when adding packages)           |\n| `--save-peer`          |       | Save to peerDependencies (only when adding packages)     |\n| `--save-optional`      | `-O`  | Save to optionalDependencies (only when adding packages) |\n| `--save-catalog`       |       | Save to default catalog (only when adding packages)      |\n| `--global`             | `-g`  | Install globally (only when adding packages)             |\n\n### Command Mapping\n\n#### Install Command Mapping\n\n- https://pnpm.io/cli/install\n- https://yarnpkg.com/cli/install\n- https://classic.yarnpkg.com/en/docs/cli/install\n- https://docs.npmjs.com/cli/v11/commands/npm-install\n\n| Vite+ Flag             | pnpm                   | yarn@1                 | yarn@2+                                     | npm                         | Description                          |\n| ---------------------- | ---------------------- | ---------------------- | ------------------------------------------- | --------------------------- | ------------------------------------ |\n| `vp install`           | `pnpm install`         | `yarn install`         | `yarn install`                              | `npm install`               | Install all dependencies             |\n| `--prod, -P`           | `--prod`               | `--production`         | N/A (use `.yarnrc.yml`)                     | `--omit=dev`                | Skip devDependencies                 |\n| `--dev, -D`            | `--dev`                | N/A                    | N/A                                         | `--include=dev --omit=prod` | Only devDependencies                 |\n| `--no-optional`        | `--no-optional`        | `--ignore-optional`    | N/A                                         | `--omit=optional`           | Skip optionalDependencies            |\n| `--frozen-lockfile`    | `--frozen-lockfile`    | `--frozen-lockfile`    | `--immutable`                               | `ci` (use `npm ci`)         | Fail if lockfile outdated            |\n| `--no-frozen-lockfile` | `--no-frozen-lockfile` | `--no-frozen-lockfile` | `--no-immutable`                            | `install` (not `ci`)        | Allow lockfile updates               |\n| `--lockfile-only`      | `--lockfile-only`      | N/A                    | `--mode update-lockfile`                    | `--package-lock-only`       | Only update lockfile                 |\n| `--prefer-offline`     | `--prefer-offline`     | `--prefer-offline`     | N/A                                         | `--prefer-offline`          | Prefer cached packages               |\n| `--offline`            | `--offline`            | `--offline`            | N/A                                         | `--offline`                 | Only use cache                       |\n| `--force, -f`          | `--force`              | `--force`              | N/A                                         | `--force`                   | Force reinstall                      |\n| `--ignore-scripts`     | `--ignore-scripts`     | `--ignore-scripts`     | `--mode skip-build`                         | `--ignore-scripts`          | Skip lifecycle scripts               |\n| `--no-lockfile`        | `--no-lockfile`        | `--no-lockfile`        | N/A                                         | `--no-package-lock`         | Skip lockfile                        |\n| `--fix-lockfile`       | `--fix-lockfile`       | N/A                    | `--refresh-lockfile`                        | N/A                         | Fix broken lockfile entries          |\n| `--shamefully-hoist`   | `--shamefully-hoist`   | N/A                    | N/A                                         | N/A                         | Flat node_modules (pnpm)             |\n| `--resolution-only`    | `--resolution-only`    | N/A                    | N/A                                         | N/A                         | Re-run resolution only (pnpm)        |\n| `--silent`             | `--silent`             | `--silent`             | N/A (use env var)                           | `--loglevel silent`         | Suppress output                      |\n| `--filter <pattern>`   | `--filter <pattern>`   | N/A                    | `workspaces foreach -A --include <pattern>` | `--workspace <pattern>`     | Target specific workspace package(s) |\n| `-w, --workspace-root` | `-w`                   | `-W`                   | N/A                                         | `--include-workspace-root`  | Install in root only                 |\n\n**Notes:**\n\n- `--frozen-lockfile`: For npm, this maps to `npm ci` command instead of `npm install`\n- `--no-frozen-lockfile`: Takes higher priority over `--frozen-lockfile` when both are specified. Passed through to the actual package manager (pnpm: `--no-frozen-lockfile`, yarn@1: `--no-frozen-lockfile`, yarn@2+: `--no-immutable`, npm: uses `npm install` instead of `npm ci`)\n- `--prod`: yarn@2+ requires configuration in `.yarnrc.yml` instead of CLI flag\n- `--ignore-scripts`: For yarn@2+, this maps to `--mode skip-build`\n- `--fix-lockfile`: Automatically fixes broken lockfile entries (pnpm and yarn@2+ only, npm does not support)\n- `--resolution-only`: Re-runs dependency resolution without installing packages. Useful for peer dependency analysis (pnpm only)\n- `--shamefully-hoist`: pnpm-specific, creates flat node_modules like npm/yarn\n- `--silent`: Suppresses output. For yarn@2+, use `YARN_ENABLE_PROGRESS=false` environment variable instead. For npm, maps to `--loglevel silent`\n\n**Add Package Mode:**\n\nWhen packages are provided as arguments (e.g., `vp install react`), the command acts as an alias for `vp add`:\n\n- `--save-exact, -E`: Save exact version rather than semver range\n- `--save-peer`: Save to peerDependencies (and devDependencies)\n- `--save-optional, -O`: Save to optionalDependencies\n- `--save-catalog`: Save to the default catalog (pnpm only)\n- `--global, -g`: Install globally\n\n#### Workspace Filter Patterns\n\nBased on pnpm's filter syntax:\n\n| Pattern      | Description              | Example                                    |\n| ------------ | ------------------------ | ------------------------------------------ |\n| `<pkg-name>` | Exact package name       | `--filter app`                             |\n| `<pattern>*` | Wildcard match           | `--filter \"app*\"` matches app, app-web     |\n| `@<scope>/*` | Scope match              | `--filter \"@myorg/*\"`                      |\n| `!<pattern>` | Exclude pattern          | `--filter \"!test*\"` excludes test packages |\n| `<pkg>...`   | Package and dependencies | `--filter \"app...\"`                        |\n| `...<pkg>`   | Package and dependents   | `--filter \"...utils\"`                      |\n\n**Multiple Filters:**\n\n```bash\nvp install --filter app --filter web  # Install for both app and web\nvp install --filter \"app*\" --filter \"!app-test\"  # app* except app-test\n```\n\n**Note**: For pnpm, `--filter` must come before the command (e.g., `pnpm --filter app install`). For yarn/npm, it's integrated into the command structure.\n\n#### Pass-Through Arguments\n\nAdditional parameters not covered by Vite+ can be handled through pass-through arguments.\n\nAll arguments after `--` will be passed through to the package manager.\n\n```bash\nvp install -- --use-stderr\n\n-> pnpm install --use-stderr\n-> yarn install --use-stderr\n-> npm install --use-stderr\n```\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_global/src/lib.rs`\n\nAdd new command variant:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Install all dependencies\n    #[command(disable_help_flag = true, alias = \"i\")]\n    Install {\n        /// Do not install devDependencies\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only install devDependencies\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Do not install optionalDependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Fail if lockfile needs to be updated (CI mode)\n        #[arg(long)]\n        frozen_lockfile: bool,\n\n        /// Only update lockfile, don't install\n        #[arg(long)]\n        lockfile_only: bool,\n\n        /// Use cached packages when available\n        #[arg(long)]\n        prefer_offline: bool,\n\n        /// Only use packages already in cache\n        #[arg(long)]\n        offline: bool,\n\n        /// Force reinstall all dependencies\n        #[arg(short = 'f', long)]\n        force: bool,\n\n        /// Do not run lifecycle scripts\n        #[arg(long)]\n        ignore_scripts: bool,\n\n        /// Don't read or generate lockfile\n        #[arg(long)]\n        no_lockfile: bool,\n\n        /// Fix broken lockfile entries\n        #[arg(long)]\n        fix_lockfile: bool,\n\n        /// Create flat node_modules (pnpm only)\n        #[arg(long)]\n        shamefully_hoist: bool,\n\n        /// Re-run resolution for peer dependency analysis\n        #[arg(long)]\n        resolution_only: bool,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Vec<String>,\n\n        /// Install in workspace root only\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/commands/install.rs`\n\nAdd methods to translate commands:\n\n```rust\nimpl PackageManager {\n    /// Build install command arguments\n    pub fn build_install_args(&self, options: &InstallOptions) -> InstallCommandResult {\n        let mut args = Vec::new();\n        let mut use_ci = false;\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                // pnpm: --filter must come before command\n                for filter in &options.filters {\n                    args.push(\"--filter\".to_string());\n                    args.push(filter.clone());\n                }\n\n                args.push(\"install\".to_string());\n\n                if options.prod {\n                    args.push(\"--prod\".to_string());\n                }\n                if options.dev {\n                    args.push(\"--dev\".to_string());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".to_string());\n                }\n                if options.frozen_lockfile {\n                    args.push(\"--frozen-lockfile\".to_string());\n                }\n                if options.lockfile_only {\n                    args.push(\"--lockfile-only\".to_string());\n                }\n                if options.prefer_offline {\n                    args.push(\"--prefer-offline\".to_string());\n                }\n                if options.offline {\n                    args.push(\"--offline\".to_string());\n                }\n                if options.force {\n                    args.push(\"--force\".to_string());\n                }\n                if options.ignore_scripts {\n                    args.push(\"--ignore-scripts\".to_string());\n                }\n                if options.no_lockfile {\n                    args.push(\"--no-lockfile\".to_string());\n                }\n                if options.fix_lockfile {\n                    args.push(\"--fix-lockfile\".to_string());\n                }\n                if options.shamefully_hoist {\n                    args.push(\"--shamefully-hoist\".to_string());\n                }\n                if options.resolution_only {\n                    args.push(\"--resolution-only\".to_string());\n                }\n                if options.workspace_root {\n                    args.push(\"-w\".to_string());\n                }\n            }\n\n            PackageManagerType::Yarn => {\n                args.push(\"install\".to_string());\n\n                if self.is_yarn_berry() {\n                    // yarn@2+ (Berry)\n                    if options.frozen_lockfile {\n                        args.push(\"--immutable\".to_string());\n                    }\n                    if options.lockfile_only {\n                        args.push(\"--mode\".to_string());\n                        args.push(\"update-lockfile\".to_string());\n                    }\n                    if options.fix_lockfile {\n                        args.push(\"--refresh-lockfile\".to_string());\n                    }\n                    if options.ignore_scripts {\n                        args.push(\"--mode\".to_string());\n                        args.push(\"skip-build\".to_string());\n                    }\n                    if options.resolution_only {\n                        eprintln!(\"Warning: yarn@2+ does not support --resolution-only\");\n                    }\n                    // Note: yarn@2+ uses .yarnrc.yml for prod\n                    if options.prod {\n                        eprintln!(\"Warning: yarn@2+ requires configuration in .yarnrc.yml for --prod behavior\");\n                    }\n                    // yarn@2+ filter is handled differently - needs workspaces foreach\n                    if !options.filters.is_empty() {\n                        // For yarn@2+, we need to use: yarn workspaces foreach -A --include <pattern> install\n                        // This requires restructuring the command\n                        args.clear();\n                        args.push(\"workspaces\".to_string());\n                        args.push(\"foreach\".to_string());\n                        args.push(\"-A\".to_string());\n                        for filter in &options.filters {\n                            args.push(\"--include\".to_string());\n                            args.push(filter.clone());\n                        }\n                        args.push(\"install\".to_string());\n                    }\n                } else {\n                    // yarn@1 (Classic)\n                    if options.prod {\n                        args.push(\"--production\".to_string());\n                    }\n                    if options.no_optional {\n                        args.push(\"--ignore-optional\".to_string());\n                    }\n                    if options.frozen_lockfile {\n                        args.push(\"--frozen-lockfile\".to_string());\n                    }\n                    if options.prefer_offline {\n                        args.push(\"--prefer-offline\".to_string());\n                    }\n                    if options.offline {\n                        args.push(\"--offline\".to_string());\n                    }\n                    if options.force {\n                        args.push(\"--force\".to_string());\n                    }\n                    if options.ignore_scripts {\n                        args.push(\"--ignore-scripts\".to_string());\n                    }\n                    if options.no_lockfile {\n                        args.push(\"--no-lockfile\".to_string());\n                    }\n                    if options.fix_lockfile {\n                        eprintln!(\"Warning: yarn@1 does not support --fix-lockfile\");\n                    }\n                    if options.resolution_only {\n                        eprintln!(\"Warning: yarn@1 does not support --resolution-only\");\n                    }\n                    if options.workspace_root {\n                        args.push(\"-W\".to_string());\n                    }\n                }\n            }\n\n            PackageManagerType::Npm => {\n                // npm: Use `npm ci` for frozen-lockfile\n                if options.frozen_lockfile {\n                    args.push(\"ci\".to_string());\n                    use_ci = true;\n                } else {\n                    args.push(\"install\".to_string());\n                }\n\n                if options.prod {\n                    args.push(\"--omit=dev\".to_string());\n                }\n                if options.dev && !use_ci {\n                    args.push(\"--include=dev\".to_string());\n                    args.push(\"--omit=prod\".to_string());\n                }\n                if options.no_optional {\n                    args.push(\"--omit=optional\".to_string());\n                }\n                if options.lockfile_only && !use_ci {\n                    args.push(\"--package-lock-only\".to_string());\n                }\n                if options.prefer_offline {\n                    args.push(\"--prefer-offline\".to_string());\n                }\n                if options.offline {\n                    args.push(\"--offline\".to_string());\n                }\n                if options.force && !use_ci {\n                    args.push(\"--force\".to_string());\n                }\n                if options.ignore_scripts {\n                    args.push(\"--ignore-scripts\".to_string());\n                }\n                if options.no_lockfile && !use_ci {\n                    args.push(\"--no-package-lock\".to_string());\n                }\n                if options.fix_lockfile {\n                    eprintln!(\"Warning: npm does not support --fix-lockfile\");\n                }\n                if options.resolution_only {\n                    eprintln!(\"Warning: npm does not support --resolution-only\");\n                }\n                if options.workspace_root {\n                    args.push(\"--include-workspace-root\".to_string());\n                }\n                for filter in &options.filters {\n                    args.push(\"--workspace\".to_string());\n                    args.push(filter.clone());\n                }\n            }\n        }\n\n        // Pass through extra args\n        args.extend_from_slice(&options.extra_args);\n\n        InstallCommandResult {\n            command: if use_ci { \"ci\".to_string() } else { \"install\".to_string() },\n            args,\n        }\n    }\n\n    fn is_yarn_berry(&self) -> bool {\n        // yarn@2+ is called \"Berry\"\n        !self.version.starts_with(\"1.\")\n    }\n}\n\npub struct InstallOptions {\n    pub prod: bool,\n    pub dev: bool,\n    pub no_optional: bool,\n    pub frozen_lockfile: bool,\n    pub lockfile_only: bool,\n    pub prefer_offline: bool,\n    pub offline: bool,\n    pub force: bool,\n    pub ignore_scripts: bool,\n    pub no_lockfile: bool,\n    pub fix_lockfile: bool,\n    pub shamefully_hoist: bool,\n    pub resolution_only: bool,\n    pub filters: Vec<String>,\n    pub workspace_root: bool,\n    pub extra_args: Vec<String>,\n}\n\npub struct InstallCommandResult {\n    pub command: String,\n    pub args: Vec<String>,\n}\n```\n\n#### 3. Install Command Implementation\n\n**File**: `crates/vite_global/src/install.rs` (new file)\n\n```rust\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_package_manager::{PackageManager, InstallOptions};\n\npub struct InstallCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl InstallCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(self, options: InstallOptions) -> Result<(), Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n\n        let resolve_command = package_manager.resolve_command();\n        let install_result = package_manager.build_install_args(&options);\n\n        let status = package_manager\n            .run_command(&install_result.args, &self.workspace_root)\n            .await?;\n\n        if !status.success() {\n            return Err(Error::CommandFailed {\n                command: format!(\"install\"),\n                exit_code: status.code(),\n            });\n        }\n\n        Ok(())\n    }\n}\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache install operations.\n\n**Rationale**:\n\n- Install commands modify node_modules and lockfiles\n- Side effects make caching inappropriate\n- Each execution should run fresh\n- Package managers have their own caching mechanisms\n\n### 2. Frozen Lockfile for CI\n\n**Decision**: Map `--frozen-lockfile` to `npm ci` for npm.\n\n**Rationale**:\n\n- `npm ci` is the recommended way to do clean installs in CI\n- It's faster than `npm install --frozen-lockfile`\n- Automatically removes existing node_modules\n- Better aligns with CI best practices\n\n### 3. Pass-Through Arguments\n\n**Decision**: Pass all arguments after `--` directly to package manager.\n\n**Rationale**:\n\n- Package managers have many flags (40+ for npm)\n- Maintaining complete flag mapping is error-prone\n- Pass-through allows accessing all features\n- Only translate critical differences\n\n### 4. Workspace Support\n\n**Decision**: Support workspace filtering with `--filter` flag.\n\n**Rationale**:\n\n- Monorepo workflows need selective installation\n- pnpm's filter syntax is most powerful\n- Graceful degradation for other package managers\n- Consistent with other Vite+ commands\n\n### 5. Alias Support\n\n**Decision**: Support `vp i` as alias for `vp install`.\n\n**Rationale**:\n\n- Matches npm/yarn/pnpm convention (`npm i`, `yarn`, `pnpm i`)\n- Faster to type\n- Familiar to developers\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vp install\nError: No package manager detected\nPlease run one of:\n  - vp install (after adding packageManager to package.json)\n  - Add packageManager field to package.json\n```\n\n### Lockfile Out of Date\n\n```bash\n$ vp install --frozen-lockfile\nDetected package manager: pnpm@10.15.0\nRunning: pnpm install --frozen-lockfile\n\nERR_PNPM_OUTDATED_LOCKFILE  Cannot install with \"frozen-lockfile\" because pnpm-lock.yaml is not up to date with package.json\n\nError: Command failed with exit code 1\n```\n\n### Network Error\n\n```bash\n$ vp install --offline\nDetected package manager: npm@11.0.0\nRunning: npm install --offline\n\nnpm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/some-package - Package not found in cache\n\nError: Command failed with exit code 1\n```\n\n## User Experience\n\n### Basic Install\n\n```bash\n$ vp install\nDetected package manager: pnpm@10.15.0\nRunning: pnpm install\n\nLockfile is up to date, resolution step is skipped\nPackages: +150\n+++++++++++++++++++++++++++++++++++\nProgress: resolved 150, reused 150, downloaded 0, added 150, done\n\nDone in 1.2s\n```\n\n### CI Install\n\n```bash\n$ vp install --frozen-lockfile\nDetected package manager: npm@11.0.0\nRunning: npm ci\n\nadded 150 packages in 2.3s\n\nDone in 2.3s\n```\n\n### Production Install\n\n```bash\n$ vp install --prod\nDetected package manager: pnpm@10.15.0\nRunning: pnpm install --prod\n\nPackages: +80\n++++++++++++++++++++\nProgress: resolved 80, reused 80, downloaded 0, added 80, done\n\nDone in 0.8s\n```\n\n### Workspace Install\n\n```bash\n$ vp install --filter app\nDetected package manager: pnpm@10.15.0\nRunning: pnpm --filter app install\n\nScope: 1 of 5 workspace projects\nPackages: +50\n++++++++++++++\nProgress: resolved 50, reused 50, downloaded 0, added 50, done\n\nDone in 0.5s\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Always Use Native Commands\n\n```bash\n# Let user call package manager directly\npnpm install\nyarn install\nnpm install\n```\n\n**Rejected because**:\n\n- No abstraction benefit\n- Scripts not portable\n- Requires knowing package manager\n- Inconsistent developer experience\n\n### Alternative 2: Custom Install Logic\n\nImplement our own dependency resolution and installation:\n\n```rust\n// Custom dependency resolver\nlet deps = resolve_dependencies(&package_json)?;\ndownload_packages(&deps)?;\nlink_packages(&deps)?;\n```\n\n**Rejected because**:\n\n- Enormous complexity\n- Package managers are well-tested\n- Would miss PM-specific optimizations\n- Maintenance burden\n\n### Alternative 3: Environment Variable Detection\n\n```bash\n# Detect package manager from environment\nVITE_PM=pnpm vp install\n```\n\n**Rejected because**:\n\n- Less convenient than auto-detection\n- Requires extra configuration\n- Not portable across machines\n- Existing lockfile detection works well\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Install` command variant to `Commands` enum\n2. Create `install.rs` module\n3. Implement package manager command resolution\n4. Add basic flag translation\n\n### Phase 2: Advanced Features\n\n1. Implement workspace filtering\n2. Add `--frozen-lockfile` to `npm ci` mapping\n3. Handle yarn@1 vs yarn@2+ differences\n4. Add pass-through argument support\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Integration tests with mock package managers\n3. Manual testing with real package managers\n4. CI workflow testing\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document flag compatibility matrix\n4. Add troubleshooting guide\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x\n- pnpm@10.x\n- yarn@1.x\n- yarn@4.x\n- npm@10.x\n- npm@11.x\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_basic_install() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = InstallOptions::default();\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\"]);\n}\n\n#[test]\nfn test_pnpm_prod_install() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = InstallOptions { prod: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--prod\"]);\n}\n\n#[test]\nfn test_npm_frozen_lockfile_uses_ci() {\n    let pm = PackageManager::mock(PackageManagerType::Npm, \"11.0.0\");\n    let options = InstallOptions { frozen_lockfile: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.command, \"ci\");\n}\n\n#[test]\nfn test_yarn_berry_frozen_lockfile() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn, \"4.0.0\");\n    let options = InstallOptions { frozen_lockfile: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--immutable\"]);\n}\n\n#[test]\nfn test_pnpm_filter() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = InstallOptions {\n        filters: vec![\"app\".to_string()],\n        ..Default::default()\n    };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"--filter\", \"app\", \"install\"]);\n}\n\n#[test]\nfn test_npm_workspace_filter() {\n    let pm = PackageManager::mock(PackageManagerType::Npm, \"11.0.0\");\n    let options = InstallOptions {\n        filters: vec![\"app\".to_string()],\n        ..Default::default()\n    };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--workspace\", \"app\"]);\n}\n\n#[test]\nfn test_pnpm_fix_lockfile() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = InstallOptions { fix_lockfile: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--fix-lockfile\"]);\n}\n\n#[test]\nfn test_yarn_berry_fix_lockfile() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn, \"4.0.0\");\n    let options = InstallOptions { fix_lockfile: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--refresh-lockfile\"]);\n}\n\n#[test]\nfn test_yarn_berry_ignore_scripts() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn, \"4.0.0\");\n    let options = InstallOptions { ignore_scripts: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--mode\", \"skip-build\"]);\n}\n\n#[test]\nfn test_pnpm_resolution_only() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm, \"10.0.0\");\n    let options = InstallOptions { resolution_only: true, ..Default::default() };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"install\", \"--resolution-only\"]);\n}\n\n#[test]\nfn test_yarn_berry_filter() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn, \"4.0.0\");\n    let options = InstallOptions {\n        filters: vec![\"app\".to_string()],\n        ..Default::default()\n    };\n    let result = pm.build_install_args(&options);\n    assert_eq!(result.args, vec![\"workspaces\", \"foreach\", \"-A\", \"--include\", \"app\", \"install\"]);\n}\n```\n\n### Integration Tests\n\nCreate fixtures for testing with each package manager:\n\n```\nfixtures/install-test/\n  pnpm-workspace.yaml\n  package.json\n  packages/\n    app/\n      package.json\n    utils/\n      package.json\n  test-steps.json\n```\n\nTest cases:\n\n1. Basic install\n2. Production install\n3. Frozen lockfile install\n4. Workspace filter install\n5. Recursive install\n6. Offline install\n7. Force reinstall\n8. Ignore scripts install\n\n## CLI Help Output\n\n```bash\n$ vp install --help\nInstall all dependencies, or add packages if package names are provided\n\nUsage: vp install [OPTIONS] [PACKAGES]...\n\nAliases: i\n\nOptions:\n  -P, --prod               Do not install devDependencies\n  -D, --dev                Only install devDependencies (install) / Save to devDependencies (add)\n      --no-optional        Do not install optionalDependencies\n      --frozen-lockfile    Fail if lockfile needs to be updated (CI mode)\n      --no-frozen-lockfile Allow lockfile updates (opposite of --frozen-lockfile)\n      --lockfile-only      Only update lockfile, don't install\n      --prefer-offline     Use cached packages when available\n      --offline            Only use packages already in cache\n  -f, --force              Force reinstall all dependencies\n      --ignore-scripts     Do not run lifecycle scripts\n      --no-lockfile        Don't read or generate lockfile\n      --fix-lockfile       Fix broken lockfile entries\n      --shamefully-hoist   Create flat node_modules (pnpm only)\n      --resolution-only    Re-run resolution for peer dependency analysis\n      --silent             Suppress output (silent mode)\n      --filter <PATTERN>   Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root     Install in workspace root only\n  -E, --save-exact         Save exact version (only when adding packages)\n      --save-peer          Save to peerDependencies (only when adding packages)\n  -O, --save-optional      Save to optionalDependencies (only when adding packages)\n      --save-catalog       Save to default catalog (only when adding packages)\n  -g, --global             Install globally (only when adding packages)\n  -h, --help               Print help\n\nExamples:\n  vp install                      # Install all dependencies\n  vp i                            # Short alias\n  vp install --prod               # Production install\n  vp install --frozen-lockfile    # CI mode (strict lockfile)\n  vp install --filter app         # Install for specific package\n  vp install --silent             # Silent install\n  vp install react                # Add react (alias for vp add)\n  vp install -D typescript        # Add typescript as devDependency\n  vp install --save-peer react    # Add react as peerDependency\n```\n\n## Performance Considerations\n\n1. **Delegate to Package Manager**: Leverage PM's built-in optimizations\n2. **No Additional Overhead**: Minimal processing before running PM command\n3. **Cache Utilization**: Support `--prefer-offline` and `--offline` flags\n4. **Parallel Installation**: Package managers handle parallelization\n\n## Security Considerations\n\n1. **Script Execution**: `--ignore-scripts` prevents untrusted script execution\n2. **Lockfile Integrity**: `--frozen-lockfile` ensures reproducible installs\n3. **Network Security**: Package managers handle registry authentication\n4. **Pass-Through Safety**: Arguments are passed through safely\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New command is additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Package Manager Compatibility Matrix\n\n| Feature                | pnpm | yarn@1 | yarn@2+                 | npm             | Notes                     |\n| ---------------------- | ---- | ------ | ----------------------- | --------------- | ------------------------- |\n| Basic install          | ✅   | ✅     | ✅                      | ✅              | All supported             |\n| `--prod`               | ✅   | ✅     | ⚠️                      | ✅              | yarn@2+ needs .yarnrc.yml |\n| `--dev`                | ✅   | ❌     | ❌                      | ✅              | Limited support           |\n| `--no-optional`        | ✅   | ✅     | ⚠️                      | ✅              | yarn@2+ needs .yarnrc.yml |\n| `--frozen-lockfile`    | ✅   | ✅     | ✅ `--immutable`        | ✅ `ci`         | npm uses `npm ci`         |\n| `--no-frozen-lockfile` | ✅   | ✅     | ✅ `--no-immutable`     | ✅ `install`    | Pass through to PM        |\n| `--lockfile-only`      | ✅   | ❌     | ✅                      | ✅              | yarn@1 not supported      |\n| `--prefer-offline`     | ✅   | ✅     | ❌                      | ✅              | yarn@2+ not supported     |\n| `--offline`            | ✅   | ✅     | ❌                      | ✅              | yarn@2+ not supported     |\n| `--force`              | ✅   | ✅     | ❌                      | ✅              | yarn@2+ not supported     |\n| `--ignore-scripts`     | ✅   | ✅     | ✅ `--mode skip-build`  | ✅              | All supported             |\n| `--no-lockfile`        | ✅   | ✅     | ❌                      | ✅              | yarn@2+ not supported     |\n| `--fix-lockfile`       | ✅   | ❌     | ✅ `--refresh-lockfile` | ❌              | pnpm and yarn@2+ only     |\n| `--shamefully-hoist`   | ✅   | ❌     | ❌                      | ❌              | pnpm only                 |\n| `--resolution-only`    | ✅   | ❌     | ❌                      | ❌              | pnpm only                 |\n| `--silent`             | ✅   | ✅     | ⚠️ (use env var)        | ✅ `--loglevel` | yarn@2+ use env var       |\n| `--filter`             | ✅   | ❌     | ✅ `workspaces foreach` | ✅              | yarn@1 not supported      |\n\n## Future Enhancements\n\n### 1. Interactive Mode\n\n```bash\n$ vp install --interactive\n? Select packages to install:\n  [x] dependencies (150 packages)\n  [ ] devDependencies (80 packages)\n  [x] optionalDependencies (5 packages)\n```\n\n### 2. Install Progress\n\n```bash\n$ vp install --progress\nInstalling dependencies...\n[============================] 100% | 150/150 packages\n```\n\n### 3. Dependency Analysis\n\n```bash\n$ vp install --analyze\nInstalling dependencies...\n\nAdded packages:\n  react@18.3.1 (85KB)\n  react-dom@18.3.1 (120KB)\n\nTotal: 150 packages, 12.3MB\n\nDone in 2.3s\n```\n\n### 4. Selective Updates\n\n```bash\n$ vp install --update react\n# Install and update specific package\n```\n\n## Real-World Usage Examples\n\n### CI Pipeline\n\n```yaml\n# .github/workflows/ci.yml\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install dependencies\n        run: vp install --frozen-lockfile\n\n      - name: Build\n        run: vp build\n```\n\n### Docker Production Build\n\n```dockerfile\nFROM node:20-alpine\n\nWORKDIR /app\nCOPY package.json pnpm-lock.yaml ./\n\n# Production install only\nRUN npm install -g @voidzero/global && \\\n    vp install --prod --frozen-lockfile\n\nCOPY . .\nRUN vp build\n```\n\n### Monorepo Development\n\n```bash\n# Install dependencies for specific package\nvp install --filter @myorg/web-app\n\n# Force reinstall after branch switch\nvp install --force\n```\n\n### Offline Development\n\n```bash\n# Populate cache first\nvp install\n\n# Later, work offline\nvp install --offline\n```\n\n## Open Questions\n\n1. **Should we support `--check` flag?**\n   - Proposed: Add `--check` to verify lockfile without installing\n   - Similar to `pnpm install --lockfile-only` but without writing\n\n2. **Should we auto-detect CI environment?**\n   - Proposed: Auto-enable `--frozen-lockfile` in CI (like pnpm)\n   - Could check `CI` environment variable\n\n3. **Should we support package manager version pinning?**\n   - Proposed: Respect `packageManager` field in package.json\n   - Already implemented in package manager detection\n\n4. **How to handle conflicting flags?**\n   - Proposed: Let package manager handle conflicts\n   - Example: `--prod` and `--dev` together\n\n## Conclusion\n\nThis RFC proposes adding `vp install` command to provide a unified interface for installing dependencies across pnpm/yarn/npm. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports common installation flags\n- ✅ Full workspace support following pnpm's API design\n- ✅ Uses pass-through for maximum flexibility\n- ✅ No caching overhead (delegates to package manager)\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ CI-friendly with `--frozen-lockfile` support\n- ✅ Extensible for future enhancements\n\nThe implementation follows the same patterns as other package management commands (`add`, `remove`, `update`) while providing a unified, intuitive interface for dependency installation.\n"
  },
  {
    "path": "rfcs/js-runtime.md",
    "content": "# RFC: JavaScript Runtime Management (`vite_js_runtime`)\n\n## Background\n\nCurrently, vite-plus relies on the user's system-installed Node.js runtime. This creates several challenges:\n\n1. **Version inconsistency**: Different team members may have different Node.js versions installed, leading to subtle bugs and \"works on my machine\" issues\n2. **CI/CD complexity**: Build pipelines need explicit Node.js version management\n3. **No runtime pinning**: Projects cannot specify and enforce a specific Node.js version\n4. **Future extensibility**: As alternatives like Bun and Deno mature, projects may want to use different runtimes\n\nThe PackageManager implementation in `vite_install` successfully handles automatic downloading and caching of package managers (pnpm, yarn, npm). We can apply the same pattern to JavaScript runtimes.\n\n## Goals\n\n1. **Pure library design**: A library crate that receives runtime name and version as input, downloads and caches the runtime, and returns the installation path\n2. **Cross-platform support**: Handle Windows, macOS, and Linux with appropriate binaries\n3. **Consistent caching**: Use the same global cache directory pattern as PackageManager\n4. **Extensible design**: Support Node.js initially, with architecture ready for Bun and Deno\n\n## Non-Goals (Initial Version)\n\n- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `.node-version`, `engines.node`, and `devEngines.runtime`**\n- Managing multiple runtime versions simultaneously\n- Providing a version manager CLI (like nvm/fnm)\n- Supporting custom/unofficial Node.js builds\n\n## Input Format\n\nThe library accepts runtime specification as a string parameter:\n\n```\n<runtime>@<version>\n```\n\n### Examples\n\n| Runtime       | Example        |\n| ------------- | -------------- |\n| Node.js       | `node@22.13.1` |\n| Bun (future)  | `bun@1.2.0`    |\n| Deno (future) | `deno@2.0.0`   |\n\nBoth exact versions and semver ranges are supported:\n\n- Exact: `22.13.1`\n- Caret range: `^22.0.0` (>=22.0.0 <23.0.0)\n- Tilde range: `~22.13.0` (>=22.13.0 <22.14.0)\n- Latest: omit version to get the latest release\n\n## Architecture\n\n### Crate Structure\n\n```\ncrates/vite_js_runtime/\n├── Cargo.toml\n└── src/\n    ├── lib.rs              # Public API exports\n    ├── dev_engines.rs      # devEngines.runtime parsing from package.json\n    ├── error.rs            # Error types\n    ├── platform.rs         # Platform detection (Os, Arch, Platform)\n    ├── provider.rs         # JsRuntimeProvider trait and types\n    ├── providers/          # Provider implementations\n    │   ├── mod.rs\n    │   └── node.rs         # NodeProvider with version resolution\n    ├── download.rs         # Generic download utilities\n    └── runtime.rs          # JsRuntime struct and download orchestration\n```\n\n### Core Types\n\n```rust\n/// Supported JavaScript runtime types\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum JsRuntimeType {\n    Node,\n    // Future: Bun, Deno\n}\n\n/// Represents a downloaded JavaScript runtime\npub struct JsRuntime {\n    pub runtime_type: JsRuntimeType,\n    pub version: Str,                   // Resolved version (e.g., \"22.13.1\")\n    pub install_dir: AbsolutePathBuf,\n    binary_relative_path: Str,          // e.g., \"bin/node\" or \"node.exe\"\n    bin_dir_relative_path: Str,         // e.g., \"bin\" or \"\"\n}\n\n/// Archive format for runtime distributions\npub enum ArchiveFormat {\n    TarGz,  // .tar.gz (Linux, macOS)\n    Zip,    // .zip (Windows)\n}\n\n/// How to verify the integrity of a downloaded archive\npub enum HashVerification {\n    ShasumsFile { url: Str },  // Download and parse SHASUMS file\n    None,                       // No verification\n}\n\n/// Information needed to download a runtime\npub struct DownloadInfo {\n    pub archive_url: Str,\n    pub archive_filename: Str,\n    pub archive_format: ArchiveFormat,\n    pub hash_verification: HashVerification,\n    pub extracted_dir_name: Str,\n}\n```\n\n### Provider Trait\n\nThe `JsRuntimeProvider` trait abstracts runtime-specific logic, making it easy to add new runtimes:\n\n```rust\n#[async_trait]\npub trait JsRuntimeProvider: Send + Sync {\n    /// Get the name of this runtime (e.g., \"node\", \"bun\", \"deno\")\n    fn name(&self) -> &'static str;\n\n    /// Get the platform string used in download URLs\n    fn platform_string(&self, platform: Platform) -> Str;\n\n    /// Get download information for a specific version and platform\n    fn get_download_info(&self, version: &str, platform: Platform) -> DownloadInfo;\n\n    /// Get the relative path to the runtime binary from the install directory\n    fn binary_relative_path(&self, platform: Platform) -> Str;\n\n    /// Get the relative path to the bin directory from the install directory\n    fn bin_dir_relative_path(&self, platform: Platform) -> Str;\n\n    /// Parse a SHASUMS file to extract the hash for a specific filename\n    fn parse_shasums(&self, shasums_content: &str, filename: &str) -> Result<Str, Error>;\n}\n```\n\n### Adding a New Runtime\n\nTo add support for a new runtime (e.g., Bun):\n\n1. Create `src/providers/bun.rs` implementing `JsRuntimeProvider`\n2. Add `Bun` variant to `JsRuntimeType` enum\n3. Add match arm in `download_runtime()` to use the new provider\n4. Export the provider from `src/providers/mod.rs`\n\n### Public API\n\n```rust\n/// Download and cache a JavaScript runtime by exact version\npub async fn download_runtime(\n    runtime_type: JsRuntimeType,\n    version: &str,           // Exact version (e.g., \"22.13.1\")\n) -> Result<JsRuntime, Error>;\n\n/// Download runtime based on project's version configuration\n/// Reads from .node-version, engines.node, or devEngines.runtime (in priority order)\n/// Resolves semver ranges, downloads the matching version\npub async fn download_runtime_for_project(\n    project_path: &AbsolutePath,\n) -> Result<JsRuntime, Error>;\n\nimpl JsRuntime {\n    /// Get the path to the runtime binary (e.g., node, bun)\n    pub fn get_binary_path(&self) -> AbsolutePathBuf;\n\n    /// Get the bin directory containing the runtime\n    pub fn get_bin_prefix(&self) -> AbsolutePathBuf;\n\n    /// Get the runtime type\n    pub fn runtime_type(&self) -> JsRuntimeType;\n\n    /// Get the resolved version string (always exact, e.g., \"22.13.1\")\n    pub fn version(&self) -> &str;\n}\n\nimpl NodeProvider {\n    /// Fetch version index from nodejs.org/dist/index.json (with HTTP caching)\n    pub async fn fetch_version_index(&self) -> Result<Vec<NodeVersionEntry>, Error>;\n\n    /// Resolve version requirement (e.g., \"^24.4.0\") to exact version\n    pub async fn resolve_version(&self, version_req: &str) -> Result<Str, Error>;\n\n    /// Get latest version (first entry in index)\n    pub async fn resolve_latest_version(&self) -> Result<Str, Error>;\n}\n```\n\n### Usage Examples\n\n**Direct version download:**\n\n```rust\nuse vite_js_runtime::{JsRuntimeType, download_runtime};\n\nlet runtime = download_runtime(JsRuntimeType::Node, \"22.13.1\").await?;\nprintln!(\"Node.js installed at: {}\", runtime.get_binary_path());\nprintln!(\"Version: {}\", runtime.version()); // \"22.13.1\"\n```\n\n**Project-based download (reads from .node-version, engines.node, or devEngines.runtime):**\n\n```rust\nuse vite_js_runtime::download_runtime_for_project;\nuse vite_path::AbsolutePathBuf;\n\nlet project_path = AbsolutePathBuf::new(\"/path/to/project\".into()).unwrap();\nlet runtime = download_runtime_for_project(&project_path).await?;\n// Version is resolved from .node-version > engines.node > devEngines.runtime\n```\n\n## Cache Directory Structure\n\nFollowing the PackageManager pattern:\n\n```\n$VITE_PLUS_HOME/js_runtime/{runtime}/{version}/\n```\n\nExamples:\n\n- Linux x64: `~/.vite-plus/js_runtime/node/22.13.1/`\n- macOS ARM: `~/.vite-plus/js_runtime/node/22.13.1/`\n- Windows x64: `%USERPROFILE%\\.vite-plus\\js_runtime\\node\\22.13.1\\`\n\n### Version Index Cache\n\nThe Node.js version index is cached locally to avoid repeated network requests:\n\n```\n$VITE_PLUS_HOME/js_runtime/node/index_cache.json\n```\n\nCache structure:\n\n```json\n{\n  \"expires_at\": 1706400000,\n  \"etag\": null,\n  \"versions\": [\n    {\"version\": \"v25.5.0\", \"lts\": false},\n    {\"version\": \"v24.4.0\", \"lts\": \"Jod\"},\n    ...\n  ]\n}\n```\n\n- Default TTL: 1 hour (3600 seconds)\n- Cache is refreshed when expired\n- Falls back to full fetch if cache is corrupted\n\n### Platform Detection\n\n| OS      | Architecture | Platform String |\n| ------- | ------------ | --------------- |\n| Linux   | x64          | `linux-x64`     |\n| Linux   | ARM64        | `linux-arm64`   |\n| macOS   | x64          | `darwin-x64`    |\n| macOS   | ARM64        | `darwin-arm64`  |\n| Windows | x64          | `win-x64`       |\n| Windows | ARM64        | `win-arm64`     |\n\n## Version Source Priority\n\nThe `download_runtime_for_project` function reads Node.js version from multiple sources with the following priority:\n\n| Priority    | Source               | File            | Example                               | Used By                       |\n| ----------- | -------------------- | --------------- | ------------------------------------- | ----------------------------- |\n| 1 (highest) | `.node-version`      | `.node-version` | `22.13.1`                             | fnm, nvm, Netlify, Cloudflare |\n| 2           | `engines.node`       | `package.json`  | `\">=20.0.0\"`                          | Vercel, npm                   |\n| 3 (lowest)  | `devEngines.runtime` | `package.json`  | `{\"name\":\"node\",\"version\":\"^24.4.0\"}` | npm RFC                       |\n\n### `.node-version` File Format\n\nReference: https://github.com/shadowspawn/node-version-usage\n\n**Supported Formats:**\n\n| Format              | Example   | Support Level                    |\n| ------------------- | --------- | -------------------------------- |\n| Three-part version  | `20.5.0`  | Universal                        |\n| With `v` prefix     | `v20.5.0` | Universal                        |\n| Two-part version    | `20.5`    | Supported (treated as `^20.5.0`) |\n| Single-part version | `20`      | Supported (treated as `^20.0.0`) |\n\n**Format Rules:**\n\n1. Single line with Unix line ending (`\\n`)\n2. Trim whitespace from both ends\n3. Optional `v` prefix - normalized by stripping\n4. No comments - entire line is the version\n\n### `engines.node` Format\n\nStandard npm `engines` field in package.json:\n\n```json\n{\n  \"engines\": {\n    \"node\": \">=20.0.0\"\n  }\n}\n```\n\n### `devEngines.runtime` Format\n\nFollowing the [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md):\n\n**Single Runtime:**\n\n```json\n{\n  \"devEngines\": {\n    \"runtime\": {\n      \"name\": \"node\",\n      \"version\": \"^24.4.0\",\n      \"onFail\": \"download\"\n    }\n  }\n}\n```\n\n**Multiple Runtimes (Array):**\n\n```json\n{\n  \"devEngines\": {\n    \"runtime\": [\n      {\n        \"name\": \"node\",\n        \"version\": \"^24.4.0\",\n        \"onFail\": \"download\"\n      },\n      {\n        \"name\": \"deno\",\n        \"version\": \"^2.4.3\",\n        \"onFail\": \"download\"\n      }\n    ]\n  }\n}\n```\n\n**Note:** Currently only the `\"node\"` runtime is supported. Other runtimes are ignored.\n\n### Version Validation\n\nBefore using a version string from any source, it is normalized and validated:\n\n1. **Trim whitespace**: Leading and trailing whitespace is removed\n2. **Validate as semver**: The version must be either:\n   - An exact version (e.g., `20.18.0`, `v20.18.0`)\n   - A valid semver range (e.g., `^20.0.0`, `>=18 <21`, `20.x`, `*`)\n3. **Invalid versions are ignored**: If validation fails, a warning is printed and the source is skipped\n\n**Example warning:**\n\n```\nwarning: invalid version 'latest' in .node-version, ignoring\n```\n\nThis allows fallthrough to lower-priority sources when a higher-priority source contains an invalid version.\n\n### Version Resolution\n\nThe version resolution is optimized to minimize network requests:\n\n| Version Specified  | Local Cache | Network Request | Result                     |\n| ------------------ | ----------- | --------------- | -------------------------- |\n| Exact (`20.18.0`)  | -           | **No**          | Use exact version directly |\n| Range (`^20.18.0`) | Match found | **No**          | Use cached version         |\n| Range (`^20.18.0`) | No match    | **Yes**         | Resolve from network       |\n| Empty/None         | Match found | **No**          | Use latest cached version  |\n| Empty/None         | No match    | **Yes**         | Get latest LTS version     |\n\n**Exact versions** (e.g., `20.18.0`, `v20.18.0`) are detected using `node_semver::Version::parse()` and used directly without network validation. The `v` prefix is normalized (stripped) since download URLs already add it.\n\n**Partial versions** like `20` or `20.18` are treated as ranges by the `node-semver` crate.\n\n**Semver ranges** (e.g., `^24.4.0`) trigger version resolution:\n\n1. First, check locally cached Node.js installations for a version that satisfies the range\n2. If a matching cached version exists, use the highest one (no network request)\n3. Otherwise, fetch the version index from `https://nodejs.org/dist/index.json`\n4. Cache the index locally with 1-hour TTL (supports ETag-based conditional requests)\n5. Use `node-semver` crate for npm-compatible range matching\n6. Return the highest version that satisfies the range\n\n### Mismatch Detection\n\nWhen the resolved version from the highest priority source does NOT satisfy constraints from lower priority sources, a warning is emitted.\n\n| .node-version | engines.node | devEngines | Resolved             | Warning?                         |\n| ------------- | ------------ | ---------- | -------------------- | -------------------------------- |\n| `22.13.1`     | `>=20.0.0`   | -          | `22.13.1`            | No (22.13.1 satisfies >=20)      |\n| `22.13.1`     | `>=24.0.0`   | -          | `22.13.1`            | **Yes** (22.13.1 < 24)           |\n| -             | `>=20.0.0`   | `^24.4.0`  | latest matching >=20 | No (if resolved >= 24)           |\n| `20.18.0`     | -            | `^24.4.0`  | `20.18.0`            | **Yes** (20 doesn't satisfy ^24) |\n\n### Fallback Behavior\n\nWhen no version source exists:\n\n1. Check local cache for installed Node.js versions\n2. Use the **latest installed version** (if any exist)\n3. If no cached versions exist, fetch and use latest LTS from network\n\nThis optimizes for:\n\n- Avoiding unnecessary network requests\n- Using what the user already has installed\n\n**Note:** `.node-version` is only written explicitly via `vp env pin`.\n\n## Download Sources\n\n### Node.js\n\nOfficial distribution from nodejs.org:\n\n```\nhttps://nodejs.org/dist/v{version}/node-v{version}-{platform}.{ext}\n```\n\n| Platform | Archive Format | Example                             |\n| -------- | -------------- | ----------------------------------- |\n| Linux    | `.tar.gz`      | `node-v22.13.1-linux-x64.tar.gz`    |\n| macOS    | `.tar.gz`      | `node-v22.13.1-darwin-arm64.tar.gz` |\n| Windows  | `.zip`         | `node-v22.13.1-win-x64.zip`         |\n\n### Custom Mirror Support\n\nThe distribution URL can be overridden using the `VITE_NODE_DIST_MIRROR` environment variable. This is useful for corporate environments or regions where nodejs.org might be slow or blocked.\n\n```bash\nVITE_NODE_DIST_MIRROR=https://example.com/mirrors/node vp build\n```\n\nThe mirror URL should have the same directory structure as the official distribution. Trailing slashes are automatically trimmed.\n\n### Integrity Verification\n\nNode.js provides SHASUMS256.txt for each release:\n\n```\nhttps://nodejs.org/dist/v{version}/SHASUMS256.txt\n```\n\nThe implementation verifies download integrity automatically:\n\n1. Download SHASUMS256.txt for the target version\n2. Parse and extract the SHA256 hash for the target archive filename\n3. After downloading the archive, verify it against the expected hash\n4. Fail with error if hash doesn't match (corrupted download)\n\nExample SHASUMS256.txt content:\n\n```\na1b2c3d4...  node-v22.13.1-darwin-arm64.tar.gz\ne5f6g7h8...  node-v22.13.1-darwin-x64.tar.gz\ni9j0k1l2...  node-v22.13.1-linux-arm64.tar.gz\n...\n```\n\n## Implementation Details\n\n### Download Flow\n\n```\n1. Receive runtime type and exact version as input\n\n2. Select the appropriate JsRuntimeProvider\n   └── e.g., NodeProvider for JsRuntimeType::Node\n\n3. Get download info from provider\n   ├── Platform string (e.g., \"linux-x64\", \"win-x64\")\n   ├── Archive URL and filename\n   ├── Hash verification method\n   └── Extracted directory name\n\n4. Check cache for existing installation\n   └── If exists: return cached path\n   └── If not: continue to download\n\n5. Download with atomic operations\n   ├── Create temp directory\n   ├── Download SHASUMS file and parse expected hash (via provider)\n   ├── Download archive with retry logic\n   ├── Verify archive hash\n   ├── Extract archive (tar.gz or zip based on format)\n   ├── Acquire file lock (prevent concurrent installs)\n   └── Atomic rename to final location\n\n6. Return JsRuntime with install path and relative paths\n```\n\n### Concurrent Download Protection\n\nSame pattern as PackageManager:\n\n- Use tempfile for atomic operations\n- File-based locking to prevent race conditions\n- Check cache after acquiring lock (another process may have completed)\n\n## Integration with vite_install\n\nThe `vite_install` crate can use `vite_js_runtime` to:\n\n1. Ensure the correct Node.js version before running package manager commands\n2. Use the managed Node.js to execute package manager binaries\n\n```rust\n// Example integration in vite_install\nuse vite_js_runtime::{JsRuntimeType, download_runtime};\n\nasync fn run_with_managed_node(\n    node_version: &str,\n    args: &[&str],\n) -> Result<(), Error> {\n    // Download/cache the runtime\n    let runtime = download_runtime(JsRuntimeType::Node, node_version).await?;\n\n    // Use the managed Node.js binary\n    let node_path = runtime.get_binary_path();\n\n    // Execute command with managed Node.js\n    Command::new(node_path)\n        .args(args)\n        .spawn()?\n        .wait()?;\n\n    Ok(())\n}\n```\n\n## Error Handling\n\nError variants in `vite_js_runtime::Error`:\n\n```rust\npub enum Error {\n    /// Version not found in official releases\n    VersionNotFound { runtime: Str, version: Str },\n\n    /// Platform not supported for this runtime\n    UnsupportedPlatform { platform: Str, runtime: Str },\n\n    /// Download failed after retries\n    DownloadFailed { url: Str, reason: Str },\n\n    /// Hash verification failed (download corrupted)\n    HashMismatch { filename: Str, expected: Str, actual: Str },\n\n    /// Archive extraction failed\n    ExtractionFailed { reason: Str },\n\n    /// SHASUMS file parsing failed\n    ShasumsParseFailed { reason: Str },\n\n    /// Hash not found in SHASUMS file\n    HashNotFound { filename: Str },\n\n    /// Failed to parse version index\n    VersionIndexParseFailed { reason: Str },\n\n    /// No version matching the requirement found\n    NoMatchingVersion { version_req: Str },\n\n    /// IO, HTTP, JSON, and semver errors\n    Io(std::io::Error),\n    Reqwest(reqwest::Error),\n    JoinError(tokio::task::JoinError),\n    Json(serde_json::Error),\n    SemverRange(node_semver::SemverError),\n}\n```\n\n## Testing Strategy\n\n### Unit Tests\n\n1. **Platform detection**\n   - Test all supported platform/arch combinations\n   - Test mapping to Node.js distribution names\n\n2. **Cache path generation**\n   - Verify correct directory structure\n\n### Integration Tests\n\n1. **Download and cache**\n   - Download a specific Node.js version\n   - Verify binary exists and is executable\n   - Verify cache reuse on second call\n\n2. **Integrity verification**\n   - Test successful verification against SHASUMS256.txt\n   - Test failure when archive is corrupted (hash mismatch)\n\n3. **Concurrent downloads**\n   - Simulate multiple processes downloading same version\n   - Verify no corruption or conflicts\n\n## Design Decisions\n\n### 1. Pure Library vs. Configuration-Aware\n\n**Decision**: Pure library that receives runtime name and version as input.\n\n**Rationale**:\n\n- Maximum flexibility - callers decide how to obtain the runtime specification\n- No coupling to specific configuration formats (package.json, .nvmrc, etc.)\n- Easier to test in isolation\n- Clear single responsibility: download and cache runtimes\n\n### 2. Separate Crate vs. Extending vite_install\n\n**Decision**: Create a new `vite_js_runtime` crate.\n\n**Rationale**:\n\n- Clear separation of concerns (runtime vs. package manager)\n- Reusable by other crates without pulling in package manager logic\n- Easier to maintain and test independently\n- Follows existing crate organization pattern\n\n### 3. Version Specification Format\n\n**Decision**: Support both exact versions and semver ranges.\n\n**Rationale**:\n\n- Mirrors the established `packageManager` format for exact versions\n- Semver ranges provide flexibility for automatic updates within constraints\n- Version index is cached locally (1-hour TTL) to minimize network requests\n- Uses `node-semver` crate for npm-compatible range parsing\n- `download_runtime()` takes exact versions; `download_runtime_for_project()` handles range resolution\n\n### 4. Initial Node.js Only\n\n**Decision**: Support only Node.js in the initial version.\n\n**Rationale**:\n\n- Node.js is the most widely used runtime\n- Allows focused, well-tested implementation\n- Trait-based architecture (`JsRuntimeProvider`) makes adding Bun/Deno straightforward\n- Reduces initial complexity and scope\n\n### 5. Trait-Based Provider Architecture\n\n**Decision**: Use a `JsRuntimeProvider` trait to abstract runtime-specific logic.\n\n**Rationale**:\n\n- Clean separation between generic download logic and runtime-specific details\n- Each provider encapsulates: platform strings, URL construction, hash verification, binary paths\n- Adding a new runtime only requires implementing the trait\n- Generic download utilities are reusable across all providers\n\n## Future Enhancements\n\n1. ✅ **Version aliases**: Support `latest` alias with cached version index\n2. **Bun support**: Create `BunProvider` implementing `JsRuntimeProvider`\n3. **Deno support**: Create `DenoProvider` implementing `JsRuntimeProvider`\n4. ✅ **Version ranges**: Support semver ranges like `node@^22.0.0`\n5. **Offline mode**: Full offline support (partial: ranges check local cache first)\n6. **LTS alias**: Support `lts` alias to download latest LTS version\n\n## Success Criteria\n\n1. ✅ Can download and cache Node.js by exact version specification\n2. ✅ Works on Linux, macOS, and Windows (x64 and ARM64)\n3. ✅ Verifies download integrity using SHASUMS256.txt\n4. ✅ Handles concurrent downloads safely\n5. ✅ Returns version and binary path\n6. ✅ Comprehensive test coverage\n7. ✅ Custom mirrors via `VITE_NODE_DIST_MIRROR` environment variable\n8. ✅ Support `devEngines.runtime` from package.json\n9. ✅ Support semver ranges (^, ~, etc.) with version resolution\n10. ✅ Version index caching with 1-hour TTL\n11. ✅ Support both single runtime and array of runtimes in devEngines\n12. ~~Write resolved version to `.node-version` file~~ (removed — `.node-version` is only written by `vp env pin`)\n13. ✅ Optimized version resolution (skip network for exact versions, check local cache for ranges)\n14. ✅ Multi-source version reading with priority: `.node-version` > `engines.node` > `devEngines.runtime`\n15. ✅ Support `.node-version` file format (with/without v prefix, partial versions)\n16. ✅ Support `engines.node` from package.json\n17. ✅ Warn when resolved version conflicts with lower-priority source constraints\n18. ✅ Use latest cached version when no source specified (avoid network request)\n19. ✅ Invalid version strings are ignored with warning, falling through to lower-priority sources\n\n## References\n\n- [Node.js Releases](https://nodejs.org/en/download/releases/)\n- [Node.js Distribution Index](https://nodejs.org/dist/index.json)\n- [.node-version file usage](https://github.com/shadowspawn/node-version-usage)\n- [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md)\n- [Corepack (Node.js Package Manager Manager)](https://nodejs.org/api/corepack.html)\n- [fnm (Fast Node Manager)](https://github.com/Schniz/fnm)\n- [volta (JavaScript Tool Manager)](https://volta.sh/)\n"
  },
  {
    "path": "rfcs/link-unlink-package-commands.md",
    "content": "# RFC: Vite+ Link and Unlink Package Commands\n\n## Summary\n\nAdd `vp link` (alias: `vp ln`) and `vp unlink` commands that automatically adapt to the detected package manager (pnpm/yarn/npm) for creating and removing symlinks to local packages, making them accessible system-wide or in other locations. This enables local package development and testing workflows.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands to link local packages:\n\n```bash\npnpm link --global\npnpm link --global <pkg>\nyarn link\nyarn link <package>\nnpm link\nnpm link <package>\n```\n\nThis creates friction in local development workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify local development**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm link --global                    # pnpm project - register current package\npnpm link --global react              # pnpm project - link global package\nyarn link                             # yarn project - register current package\nyarn link react                       # yarn project - link global package\nnpm link                              # npm project - register current package\nnpm link react                        # npm project - link global package\n\n# Different unlink commands\npnpm unlink --global\npnpm unlink --global react\nyarn unlink\nyarn unlink react\nnpm unlink\nnpm unlink react\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\n\n# Register current package globally\nvp link\nvp ln\n\n# Link a global package to current project\nvp link react\nvp ln lodash\n\n# Link a package from a specific directory\nvp link ./packages/my-lib\nvp link ../other-project\n\n# Workspace operations\nvp link --filter app                # Link in specific package\nvp link react --filter \"app*\"       # Link in multiple packages\n\n# Unlink operations\nvp unlink                           # Unlink current package\nvp unlink react                     # Unlink specific package\nvp unlink --filter app              # Unlink in specific workspace\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n#### Link Command\n\n```bash\nvp link [PACKAGE]\nvp ln [PACKAGE]        # Alias\n```\n\n**Examples:**\n\n```bash\n# Register current package globally (make it linkable)\nvp link\nvp ln\n\n# Link a global package to current project\nvp link react\nvp link @types/node\n\n# Link a local directory as a package\nvp link ./packages/utils\nvp link ../my-other-project\n```\n\n#### Unlink Command\n\n```bash\nvp unlink [PACKAGE] [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Unregister current package from global\nvp unlink\n\n# Unlink a package from current project\nvp unlink react\nvp unlink @types/node\n\n# Unlink in every workspace package (pnpm only)\nvp unlink --recursive\nvp unlink -r\n```\n\n### Command Mapping\n\n#### Link Command Mapping\n\n**pnpm references:**\n\n- https://pnpm.io/cli/link\n- pnpm link creates symlinks to local packages or links global packages\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/link (yarn@1)\n- https://yarnpkg.com/cli/link (yarn@2+)\n- yarn link registers/links packages\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-link\n- npm link creates symlinks between packages\n\n| Vite+ Command   | pnpm              | yarn@1            | yarn@2+           | npm              | Description                                             |\n| --------------- | ----------------- | ----------------- | ----------------- | ---------------- | ------------------------------------------------------- |\n| `vp link`       | `pnpm link`       | `yarn link`       | `yarn link`       | `npm link`       | Register current package or link to local directory     |\n| `vp link <pkg>` | `pnpm link <pkg>` | `yarn link <pkg>` | `yarn link <pkg>` | `npm link <pkg>` | Links package to current project                        |\n| `vp link <dir>` | `pnpm link <dir>` | `yarn link <dir>` | `yarn link <dir>` | `npm link <dir>` | Links package from `<dir>` directory to current project |\n\n#### Unlink Command Mapping\n\n**pnpm references:**\n\n- https://pnpm.io/cli/unlink\n- Unlinks packages from node_modules and removes global links\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/unlink (yarn@1)\n- https://yarnpkg.com/cli/unlink (yarn@2+)\n- Unlinks previously linked packages\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-uninstall\n- npm unlink removes symlinks\n\n| Vite+ Command           | pnpm                      | yarn@1              | yarn@2+             | npm                | Description                        |\n| ----------------------- | ------------------------- | ------------------- | ------------------- | ------------------ | ---------------------------------- |\n| `vp unlink`             | `pnpm unlink`             | `yarn unlink`       | `yarn unlink`       | `npm unlink`       | Unlinks current package            |\n| `vp unlink <pkg>`       | `pnpm unlink <pkg>`       | `yarn unlink <pkg>` | `yarn unlink <pkg>` | `npm unlink <pkg>` | Unlinks specific package           |\n| `vp unlink --recursive` | `pnpm unlink --recursive` | N/A                 | `yarn unlink --all` | N/A                | Unlinks in every workspace package |\n\n### Link/Unlink Behavior Differences Across Package Managers\n\n#### pnpm\n\n**Link behavior:**\n\n- `pnpm link`: Links current package dependencies to local directory\n- `pnpm link <pkg>`: Links a package to current project (searches globally and locally)\n- `pnpm link <dir>`: Links a local directory directly (no global registration)\n\n**Unlink behavior:**\n\n- `pnpm unlink`: Unlinks current package dependencies (removes symlinks)\n- `pnpm unlink <pkg>`: Unlinks specific package\n- `pnpm unlink --global`: Unlinks current package from global store\n\n#### yarn\n\n**Link behavior (yarn@1):**\n\n- `yarn link`: Registers current package globally\n- `yarn link <pkg>`: Links a global package to current project\n- No direct directory linking (need to `yarn link` in target first)\n\n**Link behavior (yarn@2+):**\n\n- `yarn link`: Creates link for current package\n- `yarn link <pkg>`: Links package\n- `yarn link <dir>`: Links local directory\n\n**Unlink behavior:**\n\n- `yarn unlink`: Unlinks current package\n- `yarn unlink <pkg>`: Unlinks specific package\n\n#### npm\n\n**Link behavior:**\n\n- `npm link`: Creates global symlink to current package\n- `npm link <pkg>`: Links global package to current project\n- `npm link <dir>`: Links local directory package\n\n**Unlink behavior:**\n\n- `npm unlink`: Removes global symlink for current package\n- `npm unlink <pkg>`: Removes package from current project\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command variants:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Link packages for local development\n    #[command(disable_help_flag = true, alias = \"ln\")]\n    Link {\n        /// Package name or directory to link\n        /// If empty, registers current package globally\n        package: Option<String>,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Unlink packages\n    #[command(disable_help_flag = true)]\n    Unlink {\n        /// Package name to unlink\n        /// If empty, unlinks current package globally\n        package: Option<String>,\n\n        /// Unlink in every workspace package (pnpm only)\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/commands/link.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\n#[derive(Debug, Default)]\npub struct LinkCommandOptions<'a> {\n    pub package: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the link command with the package manager.\n    #[must_use]\n    pub async fn run_link_command(\n        &self,\n        options: &LinkCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_link_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the link command.\n    #[must_use]\n    pub fn resolve_link_command(&self, options: &LinkCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"link\".into());\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                args.push(\"link\".into());\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"link\".into());\n            }\n        }\n\n        // Add package/directory if specified\n        if let Some(package) = options.package {\n            args.push(package.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n```\n\n**File**: `crates/vite_package_manager/src/commands/unlink.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\n#[derive(Debug, Default)]\npub struct UnlinkCommandOptions<'a> {\n    pub package: Option<&'a str>,\n    pub recursive: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the unlink command with the package manager.\n    #[must_use]\n    pub async fn run_unlink_command(\n        &self,\n        options: &UnlinkCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_unlink_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the unlink command.\n    #[must_use]\n    pub fn resolve_unlink_command(&self, options: &UnlinkCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                args.push(\"unlink\".into());\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n                args.push(\"unlink\".into());\n\n                if options.recursive {\n                    args.push(\"--all\".into());\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"unlink\".into());\n\n                if options.recursive {\n                    println!(\"Warning: npm doesn't support --recursive for unlink command\");\n                }\n            }\n        }\n\n        // Add package if specified\n        if let Some(package) = options.package {\n            args.push(package.to_string());\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n```\n\n#### 3. Link Command Implementation\n\n**File**: `crates/vite_task/src/link.rs` (new file)\n\n```rust\npub struct LinkCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl LinkCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        package: Option<String>,\n        extra_args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        let resolve_command = package_manager.resolve_command();\n\n        // Build link command options\n        let link_options = LinkCommandOptions {\n            package: package.as_deref(),\n            pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) },\n        };\n\n        let full_args = package_manager.build_link_args(&link_options);\n\n        let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result(\n            &workspace,\n            \"link\",\n            full_args.iter().map(String::as_str),\n            ResolveCommandResult {\n                bin_path: resolve_command.bin_path,\n                envs: resolve_command.envs,\n            },\n            false,\n        )?;\n\n        let mut task_graph: StableGraph<ResolvedTask, ()> = Default::default();\n        task_graph.add_node(resolved_task);\n        let summary = ExecutionPlan::plan(task_graph, false)?.execute(&workspace).await?;\n        workspace.unload().await?;\n\n        Ok(summary)\n    }\n}\n```\n\n#### 4. Unlink Command Implementation\n\n**File**: `crates/vite_task/src/unlink.rs` (new file)\n\n```rust\npub struct UnlinkCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl UnlinkCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        package: Option<String>,\n        recursive: bool,\n        extra_args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        let resolve_command = package_manager.resolve_command();\n\n        // Build unlink command options\n        let unlink_options = UnlinkCommandOptions {\n            package: package.as_deref(),\n            recursive,\n            pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) },\n        };\n\n        let full_args = package_manager.build_unlink_args(&unlink_options);\n\n        let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result(\n            &workspace,\n            \"unlink\",\n            full_args.iter().map(String::as_str),\n            ResolveCommandResult {\n                bin_path: resolve_command.bin_path,\n                envs: resolve_command.envs,\n            },\n            false,\n        )?;\n\n        let mut task_graph: StableGraph<ResolvedTask, ()> = Default::default();\n        task_graph.add_node(resolved_task);\n        let summary = ExecutionPlan::plan(task_graph, false)?.execute(&workspace).await?;\n        workspace.unload().await?;\n\n        Ok(summary)\n    }\n}\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache link/unlink operations.\n\n**Rationale**:\n\n- These commands create/remove symlinks\n- Side effects make caching inappropriate\n- Each execution should run fresh\n- Similar to how add/remove/install work\n\n### 2. Local Directory Linking\n\n**Decision**: Support linking local directories directly.\n\n**Rationale**:\n\n- Common use case for monorepo development\n- Allows testing packages before publishing\n- pnpm, yarn, and npm all support this\n- Simpler than global registration workflow\n\n**Example**:\n\n```bash\n# Link local package without global registration\nvp link ./packages/my-lib\nvp link ../other-project/packages/utils\n```\n\n### 3. Global vs Local Linking\n\n**Decision**: Support both global registration and local directory linking.\n\n**Rationale**:\n\n- Different workflows need different approaches\n- Global: For packages used across multiple projects\n- Local: For monorepo/related project development\n- Matches native package manager capabilities\n\n### 4. Recursive Unlink Support\n\n**Decision**: Support `--recursive` flag for unlink (pnpm and yarn@2+) with graceful degradation.\n\n**Rationale**:\n\n- pnpm supports `--recursive` flag to unlink in every workspace package\n- yarn@2+ supports `--all` flag for similar functionality\n- Provides workspace-wide cleanup capability\n- Warn users when unavailable on npm and yarn@1\n- Consistent with other workspace features\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vp link react\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### Feature Not Supported\n\n```bash\n$ vp unlink --recursive\nWarning: npm doesn't support --recursive for unlink command\n# Proceeds with standard unlink (without --recursive flag)\n```\n\n## User Experience\n\n### Link Success Output\n\n```bash\n$ vp link\nDetected package manager: pnpm@10.15.0\nRunning: pnpm link --global\n\n+ my-package@1.0.0\n\nDone in 0.5s\n```\n\n```bash\n$ vp link my-package\nDetected package manager: pnpm@10.15.0\nRunning: pnpm link --global my-package\n\nPackages: +1\n+\nProgress: resolved 1, reused 0, downloaded 0, added 1, done\n\ndependencies:\n+ my-package link:~/.pnpm-store/my-package\n\nDone in 1.2s\n```\n\n```bash\n$ vp link ./packages/utils\nDetected package manager: npm@11.0.0\nRunning: npm link ./packages/utils\n\nnpm WARN EBADENGINE Unsupported engine\nadded 1 package\n\nDone in 2.1s\n```\n\n### Unlink Success Output\n\n```bash\n$ vp unlink\nDetected package manager: pnpm@10.15.0\nRunning: pnpm unlink\n\n- my-package@1.0.0\n\nDone in 0.3s\n```\n\n```bash\n$ vp unlink react\nDetected package manager: yarn@4.0.0\nRunning: yarn unlink react\n\nRemoved react\n\nDone in 0.8s\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Separate Global and Local Commands\n\n```bash\nvp link:global          # Register globally\nvp link:local <dir>     # Link local directory\n```\n\n**Rejected because**:\n\n- More commands to remember\n- Doesn't match native package manager APIs\n- Less intuitive than flag-based approach\n\n### Alternative 2: Auto-Detect Link Type\n\n```bash\nvp link              # Auto-detect: global if no package, local if directory\nvp link react        # Auto-detect: global package or local directory\n```\n\n**Rejected because**:\n\n- Ambiguous behavior\n- Hard to predict what will happen\n- Explicit flags are clearer\n\n### Alternative 3: Interactive Mode\n\n```bash\n$ vp link\n? What would you like to link?\n  > Register current package globally\n    Link a global package\n    Link a local directory\n```\n\n**Rejected for initial version**:\n\n- Slower for experienced users\n- Not scriptable\n- Can be added later as optional mode\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Link` and `Unlink` command variants to `Commands` enum\n2. Create `link.rs` and `unlink.rs` modules in both crates\n3. Implement package manager command resolution\n4. Add basic error handling\n\n### Phase 2: Advanced Features\n\n1. Support local directory linking\n2. Implement pnpm-specific `--dir` flag\n3. Add npm save flags support\n4. Handle workspace filtering (pnpm only)\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Integration tests with mock package managers\n3. Test global and local linking\n4. Test workspace operations\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document package manager compatibility\n4. Add troubleshooting guide\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x\n- pnpm@10.x\n- yarn@1.x\n- yarn@4.x\n- npm@10.x\n- npm@11.x\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_link_no_package() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_link_command(&LinkCommandOptions {\n        package: None,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"link\"]);\n}\n\n#[test]\nfn test_pnpm_link_package() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_link_command(&LinkCommandOptions {\n        package: Some(\"react\"),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"link\", \"react\"]);\n}\n\n#[test]\nfn test_pnpm_link_directory() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_link_command(&LinkCommandOptions {\n        package: Some(\"./packages/utils\"),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"link\", \"./packages/utils\"]);\n}\n\n#[test]\nfn test_yarn_link_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let args = pm.resolve_link_command(&LinkCommandOptions {\n        package: None,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"link\"]);\n}\n\n#[test]\nfn test_npm_link_package() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_link_command(&LinkCommandOptions {\n        package: Some(\"react\"),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"link\", \"react\"]);\n}\n\n#[test]\nfn test_pnpm_unlink_no_package() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_unlink_command(&UnlinkCommandOptions {\n        package: None,\n        recursive: false,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"unlink\"]);\n}\n\n#[test]\nfn test_pnpm_unlink_recursive() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_unlink_command(&UnlinkCommandOptions {\n        package: None,\n        recursive: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"unlink\", \"--recursive\"]);\n}\n```\n\n### Integration Tests\n\nCreate fixtures for testing with each package manager:\n\n```\nfixtures/link-unlink-test/\n  pnpm-workspace.yaml\n  package.json\n  packages/\n    lib-a/\n      package.json\n    lib-b/\n      package.json\n  test-steps.json\n```\n\nTest cases:\n\n1. Link current package globally\n2. Link global package to project\n3. Link local directory\n4. Unlink current package\n5. Unlink specific package\n6. Unlink with --recursive (pnpm only)\n7. Warning for unsupported --recursive on yarn/npm\n\n## CLI Help Output\n\n### Link Command\n\n```bash\n$ vp link --help\nLink packages for local development\n\nUsage: vp link [PACKAGE]\n\nAliases: ln\n\nArguments:\n  [PACKAGE]  Package name or directory to link\n             If empty, registers current package globally\n\nOptions:\n  -h, --help             Print help\n\nLink Types:\n  Global Registration:   vp link (no package)\n  Link Global Package:   vp link <package-name>\n  Link Local Directory:  vp link <path>\n\nExamples:\n  vp link                        # Register current package globally\n  vp ln                          # Same as above (alias)\n  vp link react                  # Link global package 'react'\n  vp link ./packages/utils       # Link local directory\n  vp link ../my-lib              # Link from parent directory\n```\n\n### Unlink Command\n\n```bash\n$ vp unlink --help\nUnlink packages\n\nUsage: vp unlink [PACKAGE] [OPTIONS]\n\nArguments:\n  [PACKAGE]  Package name to unlink\n             If empty, unlinks current package globally\n\nOptions:\n  -r, --recursive        Unlink in every workspace package (pnpm and yarn@2+)\n  -h, --help             Print help\n\nExamples:\n  vp unlink                      # Unlink current package\n  vp unlink react                # Unlink 'react' from current project\n  vp unlink --recursive          # Unlink in all workspace packages (pnpm and yarn@2+)\n  vp unlink -r                   # Same as above (short form)\n```\n\n## Performance Considerations\n\n1. **No Caching**: Operations run directly without cache overhead\n2. **Symlink Creation**: Fast operation, minimal performance impact\n3. **Single Execution**: Unlike task runner, these are one-off operations\n4. **Auto-Detection**: Reuses existing package manager detection (already cached)\n\n## Security Considerations\n\n1. **Symlink Safety**: Symlinks are standard package manager feature\n2. **Path Validation**: Validate that directories exist before linking\n3. **No Code Execution**: Just creates/removes symlinks via package manager\n4. **Global Store**: Respects package manager's global store location\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New commands are additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Migration Path\n\n### Adoption\n\nUsers can start using immediately:\n\n```bash\n# Old way\npnpm link --global\npnpm link --global react\n\n# New way (works with any package manager)\nvp link\nvp link react\n```\n\n### Discoverability\n\nAdd to:\n\n- CLI help output\n- Documentation\n- VSCode extension suggestions\n- Shell completions\n\n## Real-World Usage Examples\n\n### Local Package Development\n\n```bash\n# Working on a shared library\ncd ~/projects/my-monorepo/packages/shared-utils\nvp link                           # Register globally\n\n# Use it in another project\ncd ~/projects/my-app\nvp link shared-utils              # Link the global package\n\n# Or link directly without global registration\ncd ~/projects/my-app\nvp link ~/projects/my-monorepo/packages/shared-utils\n```\n\n### Monorepo Development\n\n```bash\n# Unlink in all workspace packages (pnpm only)\nvp unlink --recursive             # Unlink current package from all workspaces\nvp unlink -r                      # Same as above (short form)\n```\n\n### Testing Unpublished Changes\n\n```bash\n# Develop a library\ncd ~/my-lib\nnpm version patch\nvp link\n\n# Test in consuming project\ncd ~/consuming-app\nvp link my-lib\nnpm test\n\n# Unlink when done\nvp unlink my-lib\nnpm install my-lib@latest\n```\n\n## Package Manager Compatibility\n\n| Feature              | pnpm                    | yarn@1           | yarn@2+           | npm              | Notes            |\n| -------------------- | ----------------------- | ---------------- | ----------------- | ---------------- | ---------------- |\n| Link package/dir     | `link`                  | `link`           | `link`            | `link`           | All supported    |\n| Link with package    | `link <pkg>`            | `link <pkg>`     | `link <pkg>`      | `link <pkg>`     | All supported    |\n| Link local directory | `link <dir>`            | `link <dir>`     | `link <dir>`      | `link <dir>`     | All supported    |\n| Unlink               | `unlink`                | `unlink`         | `unlink`          | `unlink`         | All supported    |\n| Recursive unlink     | ✅ `unlink --recursive` | ❌ Not supported | ✅ `unlink --all` | ❌ Not supported | pnpm and yarn@2+ |\n\n## Future Enhancements\n\n### 1. Link Status Command\n\nShow which packages are currently linked:\n\n```bash\nvp link:status\nvp link --list\n\n# Output:\nLinked packages:\n  react -> ~/.pnpm-global/5/node_modules/react\n  my-lib -> ~/projects/my-lib\n```\n\n### 2. Auto-Link Workspace Dependencies\n\nAutomatically link all workspace dependencies:\n\n```bash\nvp link --workspace-deps\n\n# Scans package.json for workspace: protocol dependencies\n# and links them automatically\n```\n\n### 3. Link Groups\n\nSave and restore link configurations:\n\n```bash\nvp link --save-config dev\nvp link --load-config dev\n\n# .vite-link.json:\n{\n  \"configs\": {\n    \"dev\": {\n      \"links\": [\n        { \"package\": \"my-lib\", \"path\": \"../my-lib\" },\n        { \"package\": \"shared-utils\", \"path\": \"./packages/utils\" }\n      ]\n    }\n  }\n}\n```\n\n### 4. Link Verification\n\nVerify linked packages are valid:\n\n```bash\nvp link --verify\n\n# Checks that all symlinks point to valid directories\n# Reports broken links\n```\n\n## Open Questions\n\n1. **Should we validate directory existence before linking?**\n   - Proposed: Yes, provide clear error if directory doesn't exist\n   - Better UX than cryptic package manager errors\n\n2. **Should we support relative paths?**\n   - Proposed: Yes, resolve relative paths before passing to package manager\n   - Makes commands more intuitive from any location\n\n3. **Should we warn when linking without global registration on yarn/npm?**\n   - Proposed: No, this is standard behavior\n   - Users expect this workflow\n\n4. **Should we support unlinking all packages at once?**\n   - Proposed: Later enhancement, not MVP\n   - Use case: \"clean slate\" before testing\n\n5. **Should we provide better error messages for common issues?**\n   - Proposed: Yes, detect common errors and provide helpful suggestions\n   - Example: Package not found → \"Did you run 'vp link' in the package directory first?\"\n\n## Success Metrics\n\n1. **Adoption**: % of users using `vp link/unlink` vs direct package manager\n2. **Error Rate**: Track command failures vs package manager direct usage\n3. **User Feedback**: Survey/issues about command ergonomics\n4. **Performance**: Measure overhead vs direct package manager calls (<100ms target)\n\n## Conclusion\n\nThis RFC proposes adding `vp link` and `vp unlink` commands to provide a unified interface for local package development across pnpm/yarn/npm. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports both package and local directory linking\n- ✅ Minimal options for simplicity (only --recursive for unlink)\n- ✅ Consistent behavior across all package managers\n- ✅ Clear error messages and warnings\n- ✅ No caching overhead\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ Extensible for future enhancements\n\nThe implementation follows the same patterns as other package manager commands while keeping the interface simple and intuitive for local package development workflows.\n"
  },
  {
    "path": "rfcs/merge-global-and-local-cli.md",
    "content": "# RFC: Merge Global and Local CLI into a Single Package\n\n## Background\n\nPreviously, the CLI was split across two npm packages:\n\n- **`vite-plus`** (`packages/cli/`) — The local CLI, installed as a project devDependency. Handles build, test, lint, fmt, run, and other task commands via NAPI bindings to Rust.\n- **`vite-plus-cli`** (`packages/global/`) — The global CLI, installed to `~/.vite-plus/`. Handles create, migrate, version, and package manager commands. Had its own NAPI binding crate, rolldown build, install scripts, and snap tests.\n\nThe Rust binary `vp` (`crates/vite_global_cli/`) acted as the entry point, delegating to `packages/global/dist/index.js` which detected the local `vite-plus` installation and forwarded commands accordingly.\n\n**Problems with the two-package approach:**\n\n1. Two separate NAPI binding crates with overlapping dependencies\n2. Two separate build pipelines (tsc for local, rolldown for global)\n3. Two npm packages to publish and version\n4. A JS shim layer (`dist/index.js`) for detecting/installing local vite-plus\n5. Complex CI workflows to build, test, and release both packages\n6. Duplicated utilities and types across packages\n\n## Goals\n\n1. Merge `packages/global/` (`vite-plus-cli`) into `packages/cli/` (`vite-plus`)\n2. Publish a single npm package: `vite-plus`\n3. Unify the NAPI binding crate\n4. Replace the JS shim with direct Rust resolution via `oxc_resolver`\n5. Simplify CI build and release pipelines\n6. Keep all existing functionality working\n\n## Architecture (After Merge)\n\n### Single Package: `packages/cli/` (`vite-plus`)\n\n```\npackages/cli/\n├── bin/vp                    # Node.js entry script\n├── binding/                  # Unified NAPI binding crate (migration, package_manager, utils)\n├── src/\n│   ├── bin.ts                # Unified entry point for both local and global commands\n│   ├── create/               # vp create command (from global)\n│   ├── migration/            # vp migrate command (from global)\n│   ├── version.ts            # vp --version (from global)\n│   ├── utils/                # Shared utilities (from global-utils)\n│   ├── types/                # Shared types (from global-types)\n│   ├── resolve-*.ts          # Local CLI tool resolvers\n│   └── ...                   # Other local CLI source files\n├── dist/                     # tsc output (local CLI)\n│   ├── bin.js                # Compiled entry point\n│   └── global/               # rolldown output (global CLI chunks)\n│       ├── create.js\n│       ├── migrate.js\n│       └── version.js\n├── install.sh / install.ps1  # Global install scripts\n├── templates/                # Project templates\n├── rules/                    # Oxlint rules\n├── snap-tests/               # Local CLI snap tests\n└── snap-tests-global/        # Global CLI snap tests\n```\n\n### Global Install Directory (`~/.vite-plus/`)\n\nThe global install directory uses a wrapper package pattern. Each version directory\ndeclares `vite-plus` as an npm dependency instead of extracting its internals directly.\nThis decouples the `vp` binary from vite-plus's internal file layout.\n\n```\n~/.vite-plus/\n├── bin/\n│   └── vp                            # Symlink to current/bin/vp\n├── current -> <version>/             # Symlink to active version\n├── <version>/\n│   ├── bin/\n│   │   └── vp                        # Rust binary (from CLI platform package)\n│   ├── package.json                  # Wrapper: { \"dependencies\": { \"vite-plus\": \"<version>\" } }\n│   └── node_modules/\n│       ├── vite-plus/                # Installed as npm dependency\n│       │   ├── dist/bin.js           # JS entry point (found by Rust binary)\n│       │   ├── dist/global/          # Bundled global commands\n│       │   ├── binding/              # NAPI loader\n│       │   ├── templates/            # Project templates\n│       │   ├── rules/                # Oxlint rules\n│       │   └── package.json          # Real vite-plus package.json\n│       ├── @voidzero-dev/            # Platform package (via optionalDeps)\n│       │   └── vite-plus-<platform>/ # Contains .node NAPI binary\n│       └── [other transitive deps]\n├── env, env.fish, env.ps1            # Shell PATH configuration\n└── packages/                         # Globally installed packages (vp install -g)\n```\n\n**Install flows:**\n\n- **Production** (`curl -fsSL https://vite.plus | bash`):\n  Downloads CLI platform tarball from `@voidzero-dev/vite-plus-cli-{platform}` (extracts only `vp` binary),\n  generates wrapper `package.json`, runs `vp install --silent` which installs `vite-plus` + all transitive deps via npm.\n\n- **Upgrade** (`vp upgrade`):\n  Downloads CLI platform tarball from `@voidzero-dev/vite-plus-cli-{platform}` (binary only),\n  generates wrapper `package.json`, runs `vp install --silent`. No main tarball download needed.\n\n- **Local dev** (`pnpm bootstrap-cli`):\n  Copies `vp` binary, generates wrapper `package.json`, symlinks\n  `node_modules/vite-plus` to `packages/cli/` source with transitive deps\n  symlinked from `packages/cli/node_modules/`.\n\n- **CI** (`pnpm bootstrap-cli:ci --tgz <path>`):\n  Copies `vp` binary, generates wrapper `package.json` with `file:` protocol\n  refs to tgz files, runs `npm install`.\n\n### Command Routing\n\nThe Rust `vp` binary (`crates/vite_global_cli/`) routes commands in two categories:\n\n```\n                       vp <command>\n                            │\n              ┌─────────────┴──────────────┐\n              │                            │\n              ▼                            ▼\n     ┌────────────────┐         ┌────────────────┐\n     │   Category A   │         │   Category B   │\n     │    Pkg Mgr     │         │   JavaScript   │\n     │    (Rust)      │         │   (Node.js)    │\n     └───────┬────────┘         └───────┬────────┘\n             │                          │\n       Handled in                oxc_resolver finds\n       Rust directly             local vite-plus\n             │                          │\n             ▼                    ┌─────┴─────┐\n     ┌────────────────┐          │  found?   │\n     │ install        │          └─────┬─────┘\n     │ add            │           yes ╱ ╲ no\n     │ remove         │             ╱     ╲\n     │ update         │            ▼       ▼\n     │ ...            │      ┌────────┐ ┌────────┐\n     └────────────────┘      │ local  │ │ global │\n                             │ bin.js │ │ bin.js │\n                             └───┬────┘ └───┬────┘\n                                 └─────┬────┘\n                                       │\n                                       ▼\n                              ┌────────────────┐\n                              │     bin.ts      │\n                              │   routes to:    │\n                              ├────────────────┤\n                              │ build, test,    │\n                              │ lint, fmt, run  │\n                              │   → NAPI        │\n                              ├────────────────┤\n                              │ create, migrate │\n                              │ --version       │\n                              │   → dist/       │\n                              │     global/*.js │\n                              └────────────────┘\n```\n\n- **Category A (Package Manager)**: `install`, `add`, `remove`, `update`, etc. — Handled directly in Rust\n- **Category B (JavaScript)**: All other commands (`build`, `test`, `lint`, `create`, `migrate`, `--version`, etc.) — Rust uses `oxc_resolver` to find the project's local `vite-plus/dist/bin.js` and runs it. Falls back to the global installation's `dist/bin.js` if no local installation exists. The unified `bin.ts` entry point then routes to either NAPI bindings (task commands) or rolldown-bundled modules in `dist/global/` (create, migrate, version).\n\n### Global scripts_dir Resolution (Rust)\n\nThe `vp` binary auto-detects the JS scripts directory from its own location:\n\n```rust\n// Auto-detect from binary location\n// ~/.vite-plus/<version>/bin/vp -> ~/.vite-plus/<version>/node_modules/vite-plus/dist/\nlet exe_path = std::env::current_exe()?;\nlet bin_dir = exe_path.parent()?;           // ~/.vite-plus/<version>/bin/\nlet version_dir = bin_dir.parent()?;        // ~/.vite-plus/<version>/\nlet scripts_dir = version_dir.join(\"node_modules\").join(\"vite-plus\").join(\"dist\");\n```\n\n### Local vite-plus Resolution (Rust)\n\n```rust\n// Uses oxc_resolver to resolve vite-plus/package.json from the project directory\n// If found and dist/bin.js exists, runs the local installation\n// Otherwise falls back to the global installation's dist/bin.js\nfn resolve_local_vite_plus(project_path: &AbsolutePath) -> Option<AbsolutePathBuf> {\n    let resolver = Resolver::new(ResolveOptions {\n        condition_names: vec![\"import\".into(), \"node\".into()],\n        ..ResolveOptions::default()\n    });\n    let resolved = resolver.resolve(project_path, \"vite-plus/package.json\").ok()?;\n    let pkg_dir = resolved.path().parent()?;\n    let bin_js = pkg_dir.join(\"dist\").join(\"bin.js\");\n    if bin_js.exists() { AbsolutePathBuf::new(bin_js) } else { None }\n}\n```\n\n### Unified Entry Point (`bin.ts`)\n\n```typescript\n// Global commands — handled by rolldown-bundled modules in dist/global/\nif (command === 'create') {\n  await import('./global/create.js');\n} else if (command === 'migrate') {\n  await import('./global/migrate.js');\n} else if (command === '--version' || command === '-V') {\n  await import('./global/version.js');\n} else {\n  // All other commands — delegate to Rust core via NAPI binding\n  run({ lint, pack, fmt, vite, test, doc, resolveUniversalViteConfig, args });\n}\n```\n\n## Changes Summary\n\n### Completed\n\n1. **Merged all source code** from `packages/global/` into `packages/cli/`:\n   - `src/create/`, `src/migration/`, `src/version.ts` — Global commands\n   - `src/utils/`, `src/types/` — Shared utilities and types (renamed from `global-utils`, `global-types`)\n   - `binding/` — Unified NAPI crate with migration, package_manager, utils modules\n   - `install.sh`, `install.ps1` — Install scripts\n   - `templates/`, `rules/` — Assets\n   - `snap-tests-global/` — Global snap tests\n\n2. **Deleted `packages/global/`** entirely\n\n3. **Updated Rust `vp` binary** (`crates/vite_global_cli/`):\n   - Added `oxc_resolver` dependency for direct local vite-plus resolution\n   - Removed JS shim layer — no more `dist/index.js` intermediary\n   - Updated all command entry points from `index.js` to `bin.js`\n   - Changed `MAIN_PACKAGE_NAME` from `vite-plus-cli` to `vite-plus`\n   - Scripts dir resolution: `version_dir/node_modules/vite-plus/dist/`\n\n4. **Restructured global install directory** (`~/.vite-plus/<version>/`):\n   - Wrapper `package.json` declares `vite-plus` as a dependency\n   - `vite-plus` installed into `node_modules/` by npm (not extracted from tarball)\n   - `.node` NAPI binaries installed via npm optionalDependencies (not manually copied)\n   - Removed `extract_main_package()`, `strip_dev_dependencies()`, `MAIN_PACKAGE_ENTRIES`\n   - Added `generate_wrapper_package_json()` for upgrade command\n   - Simplified install scripts: only extract `vp` binary + generate wrapper\n   - Simplified `install-global-cli.ts`: symlink-based local dev, wrapper-based CI\n\n5. **Updated build system**:\n   - Added `rolldown.config.ts` to bundle global CLI modules into `dist/global/`\n   - `treeshake: false` required for dynamic imports\n   - Plugin to fix binding import paths in rolldown output\n   - Simplified root `package.json` build scripts (removed global package steps)\n\n6. **Updated CI/CD**:\n   - Simplified `build-upstream` action (removed global package build steps)\n   - Simplified `release.yml` (removed global package publish, now 3 packages instead of 4)\n   - `get_cli_version()` reads from `node_modules/vite-plus/package.json`\n\n7. **Removed `vite` bin alias** — Only `vp` binary entry remains\n\n8. **Updated package.json**:\n   - Added runtime deps: `cross-spawn`, `picocolors`\n   - Added devDeps from global: `semver`, `yaml`, `glob`, `minimatch`, `mri`, etc.\n   - Added `snap-test-global` script\n   - Added `files` entries: `AGENTS.md`, `rules`, `templates`\n\n9. **Updated documentation**: `CLAUDE.md`, `CONTRIBUTING.md`\n\n10. **Separated `vp` binary into dedicated CLI platform packages**:\n    - `@voidzero-dev/vite-plus-{platform}` packages now contain only the `.node` NAPI binding (~20MB)\n    - `@voidzero-dev/vite-plus-cli-{platform}` packages contain only the `vp` Rust binary (~5MB)\n    - `publish-native-addons.ts` creates and publishes both NAPI and CLI packages separately\n    - Install scripts (`install.sh`, `install.ps1`) construct CLI package suffix directly instead of querying optionalDependencies\n    - Upgrade registry (`registry.rs`) queries CLI packages directly instead of looking up optionalDependencies\n    - Reduces download size for `npm install vite-plus` (no longer includes unused `vp` binary)\n\n## Verification\n\n- `cargo test -p vite_global_cli` — Rust unit tests pass\n- `pnpm -F vite-plus snap-test-local` — Local CLI snap tests pass\n- `pnpm -F vite-plus snap-test-global` — Global CLI snap tests pass\n- `pnpm bootstrap-cli` — Full build and global install succeeds\n- `VITE_PLUS_VERSION=test bash packages/cli/install.sh` — Production install from npm works\n- Manual testing: `vp create`, `vp migrate`, `vp --version`, `vp build`, `vp test` all work\n"
  },
  {
    "path": "rfcs/migration-command.md",
    "content": "# RFC: Vite+ Migration Command\n\n## Background\n\nWhen transitioning to Vite+, projects typically use standalone tools like vite, oxlint, oxfmt, and vitest, each with their own dependencies and configuration files. The `vp migrate` command automates the process of consolidating these tools into the unified Vite+ toolchain.\n\n**Problem**: Manual migration is error-prone and time-consuming:\n\n- Multiple dependency entries to update in package.json\n- Various configuration files to merge (vite.config.ts, .oxlintrc, .oxfmtrc, etc.)\n- Risk of missing configurations or incorrect merging\n- Tedious process when migrating multiple packages in a monorepo\n\n**Solution**: Automated migration using [ast-grep](https://ast-grep.github.io/) for code transformation and [brush-parser](https://github.com/reubeno/brush) for shell script rewriting.\n\n**Related Commands**:\n\n- `vp create` - Uses this same migration engine after generating code (see [code-generator.md](./code-generator.md))\n- `vp migrate` - This command, for migrating existing projects\n\n## Goals\n\n1. **Dependency Consolidation**: Replace standalone vite, vitest, oxlint, oxfmt dependencies with unified vite-plus\n2. **Configuration Unification**: Merge .oxlintrc, .oxfmtrc into vite.config.ts\n3. **Safe**: Preview changes before applying\n4. **Intelligent**: Preserve custom configurations and user overrides\n5. **Monorepo-Aware**: Migrate multiple packages efficiently\n\n## Scope\n\n**What this command migrates**:\n\n- ✅ **Dependencies**: vite, vitest, oxlint, oxfmt → vite-plus\n- ✅ **Overrides**: Force vite → vite-plus (for all dependencies)\n  - npm/pnpm/bun: Adds `overrides.vite` mapping\n  - yarn: Adds `resolutions.vite` mapping\n  - **Benefit**: Code keeps `import from 'vite'` - automatically resolves to vite-plus\n- ✅ **Configuration files**:\n  - .oxlintrc → vite.config.ts (lint section)\n  - .oxfmtrc → vite.config.ts (format section)\n\n**What this command optionally migrates** (prompted):\n\n- ✅ **Git hooks**: husky + lint-staged → `vp config` + `vp staged`\n  - Rewrites `prepare: \"husky\"` → `prepare: \"vp config\"`\n  - Migrates lint-staged config into `staged` in vite.config.ts\n  - Replaces `.husky/pre-commit` with `.vite-hooks/pre-commit` using `vp staged`\n  - Removes `husky` and `lint-staged` from devDependencies\n- ✅ **ESLint → oxlint** (via `@oxlint/migrate`): converts ESLint flat config to `.oxlintrc.json`, which is then merged into `vite.config.ts` by the existing flow\n- ✅ **Prettier → oxfmt** (via `vp fmt --migrate=prettier`): converts Prettier config to `.oxfmtrc.json`, which is then merged into `vite.config.ts` by the existing flow\n\n**What this command does NOT migrate**:\n\n- ❌ Package.json scripts → vite-task.json (different feature)\n- ❌ TypeScript configuration changes\n- ❌ Build tool changes (webpack/rollup → vite)\n\nThese are **consolidation migrations**, not **feature migrations**.\n\n### Re-migration\n\nWhen a project already has `vite-plus` in its dependencies, `vp migrate` skips the full dependency/config migration and only runs remaining partial migrations:\n\n- **ESLint → Oxlint**: If `eslint` is still present with a flat config, offers ESLint migration\n- **Prettier → Oxfmt**: If `prettier` is still present with a config file, offers Prettier migration\n- **Git hooks**: If `husky` and/or `lint-staged` are still present, offers hooks migration\n\nAll checks run independently — a project may need one, some, or none.\n\n## Command Usage\n\n```bash\nvp migrate\n```\n\n## Migration Process\n\nThe migration uses a **two-phase architecture**: all user prompts are collected upfront (Phase 1), then all work is executed without interruption (Phase 2). This lets the user see the full picture before any changes begin.\n\n### Phase 1: Collect User Decisions\n\nAll prompts are presented sequentially before any work begins:\n\n1. **Confirm migration**: \"Migrate this project to Vite+?\"\n2. **Package manager**: Select or auto-detect (pnpm/npm/yarn)\n3. **Pre-commit hooks**: \"Set up pre-commit hooks?\" + preflight validation (read-only check for git root, existing hook tools)\n4. **Agent selection**: \"Which agents are you using?\" (multiselect)\n5. **Agent file conflicts**: Per existing file — \"Agent instructions already exist at X. Append or Skip?\" (only for files without auto-update markers)\n6. **Editor selection**: \"Which editor are you using?\"\n7. **Editor file conflicts**: Per existing file — \"X already exists. Merge or Skip?\"\n8. **ESLint migration**: If ESLint config detected — \"Migrate ESLint rules to Oxlint?\"\n9. **Prettier migration**: If Prettier config detected — \"Migrate Prettier to Oxfmt?\"\n10. **Migration plan summary**: Display all planned actions before execution\n\nIn non-interactive mode (`--no-interactive`), Phase 1 uses defaults (no prompts shown, no summary displayed).\n\n```bash\n$ vp migrate\n\nVITE+ - The Unified Toolchain for the Web\n\n◆ Migrate this project to Vite+?\n│ Yes\n\n◆ Which package manager would you like to use?\n│ pnpm (recommended)\n\n◆ Set up pre-commit hooks?\n│ Yes\n\n◆ Which agents are you using?\n│ Claude Code\n\n◆ CLAUDE.md already exists.\n│ Append\n\n◆ Which editor are you using?\n│ VSCode\n\n◆ .vscode/settings.json already exists.\n│ Merge\n\n◆ Migrate ESLint rules to Oxlint using @oxlint/migrate?\n│ Yes\n\n◆ Migrate Prettier to Oxfmt?\n│ Yes\n\nMigration plan:\n- Install pnpm and dependencies\n- Rewrite configs and dependencies for Vite+\n- Migrate ESLint rules to Oxlint\n- Migrate Prettier to Oxfmt\n- Set up pre-commit hooks\n- Write agent instructions (CLAUDE.md, append)\n- Write editor config (.vscode/, merge)\n```\n\n### Phase 2: Execute Without Prompts\n\nAll work runs sequentially with spinner feedback — no further user interaction:\n\n1. **Download package manager** + version validation\n2. **Upgrade yarn** if needed (yarn <4.10.0)\n3. **Run `vp install`** to prepare dependencies\n4. **Check vite/vitest versions** (abort if unsupported)\n5. **Migrate ESLint → Oxlint** (if approved in Phase 1, via `@oxlint/migrate`)\n   5b. **Migrate Prettier → Oxfmt** (if approved in Phase 1, via `vp fmt --migrate=prettier`)\n6. **Rewrite configs** (dependencies, overrides, config file merging)\n7. **Install git hooks** (if approved)\n8. **Write agent instructions** (using pre-resolved conflict decisions)\n9. **Write editor configs** (using pre-resolved conflict decisions)\n10. **Reinstall dependencies** (final `vp install`)\n\n```bash\npnpm@latest installing...\npnpm@<semver> installed\nMigrating ESLint config to Oxlint...\nESLint config migrated to .oxlintrc.json\nReplacing ESLint comments with Oxlint equivalents...\nESLint comments replaced\n✔ Removed eslint.config.mjs\n✔ Created vite.config.ts in vite.config.ts\n✔ Merged .oxlintrc.json into vite.config.ts\n✔ Merged staged config into vite.config.ts\nWrote agent instructions to AGENTS.md\n✔ Migration completed!\n```\n\n## Migration Rules\n\n### Package.json Dependencies & Overrides\n\n**Before:**\n\n```json\n{\n  \"name\": \"my-package\",\n  \"dependencies\": {\n    \"react\": \"^18.2.0\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"^8.0.0\",\n    \"vitest\": \"^4.0.0\",\n    \"oxlint\": \"^0.1.0\",\n    \"oxfmt\": \"^0.1.0\",\n    \"@vitest/browser\": \"^4.0.0\",\n    \"@vitest/browser-playwright\": \"^4.0.0\",\n    \"@vitejs/plugin-react\": \"^4.2.0\"\n  }\n}\n```\n\n**After:**\n\n```json\n{\n  \"name\": \"my-package\",\n  \"dependencies\": {\n    \"react\": \"^18.2.0\"\n  },\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\",\n    \"@vitejs/plugin-react\": \"^4.2.0\"\n  },\n  \"overrides\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  }\n}\n```\n\n**Important**:\n\n- `overrides.vite` ensures any dependency requiring `vite` gets `vite-plus` instead\n- rewrite `import from 'vite'` to `import from 'vite-plus'`\n- rewrite `import from 'vite/{name}'` to `import from 'vite-plus/{name}'`, e.g.: `import from 'vite/module-runner'` to `import from 'vite-plus/module-runner'`\n- rewrite `import from 'vitest'` to `import from 'vite-plus/test'`\n- rewrite `import from 'vitest/config'` to `import from 'vite-plus'`\n- rewrite `import from 'vitest/{name}'` to `import from 'vite-plus/test/{name}'`, e.g.: `import from 'vitest/node'` to `import from 'vite-plus/test/node'`\n- rewrite `import from '@vitest/browser'` to `import from 'vite-plus/test/browser'`\n- rewrite `import from '@vitest/browser/{name}'` to `import from 'vite-plus/test/browser/{name}'`, e.g.: `import from '@vitest/browser/context'` to `import from 'vite-plus/test/browser/context'`\n- rewrite `import from '@vitest/browser-playwright'` to `import from 'vite-plus/test/browser-playwright'`\n- rewrite `import from '@vitest/browser-playwright/{name}'` to `import from 'vite-plus/test/browser-playwright/{name}'`\n\n**Note**: For Yarn, use `resolutions` instead of `overrides`.\n\n### Oxlint Configuration\n\n**Before (.oxlintrc):**\n\n```json\n{\n  \"rules\": {\n    \"no-unused-vars\": \"error\",\n    \"no-console\": \"warn\"\n  },\n  \"ignorePatterns\": [\"dist\", \"node_modules\"]\n}\n```\n\n**After (merged into vite.config.ts):**\n\n```typescript\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n\n  // Oxlint configuration\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n    rules: {\n      'no-unused-vars': 'error',\n      'no-console': 'warn',\n    },\n    ignorePatterns: ['dist', 'node_modules'],\n  },\n});\n```\n\n> **Note**: If `tsconfig.json` contains `compilerOptions.baseUrl`, `typeAware` and `typeCheck` are not injected because oxlint's TypeScript checker does not yet support `baseUrl`. Run `npx @andrewbranch/ts5to6 --fixBaseUrl .` to migrate away from `baseUrl`.\n\n### Oxfmt Configuration\n\n**Before (.oxfmtrc):**\n\n```json\n{\n  \"printWidth\": 100,\n  \"tabWidth\": 2,\n  \"semi\": true,\n  \"singleQuote\": true,\n  \"trailingComma\": \"es5\"\n}\n```\n\n**After (merged into vite.config.ts):**\n\n```typescript\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  plugins: [],\n\n  // Oxfmt configuration\n  fmt: {\n    printWidth: 100,\n    tabWidth: 2,\n    semi: true,\n    singleQuote: true,\n    trailingComma: 'es5',\n  },\n});\n```\n\n### import namespace change to vite-plus\n\neffect files:\n\n- vitest.config.ts\n- vite.config.ts\n\n**Before (import from 'vitest/config'):**\n\n```typescript\nimport { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\n```\n\n**After (import from 'vite-plus'):**\n\n```typescript\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\n```\n\n**Before (import from 'vite'):**\n\n```typescript\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\n```\n\n**After (import from 'vite-plus'):**\n\n```typescript\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\n```\n\n### Complete Example\n\n**Before:**\n\n```\nmy-package/\n├── package.json              # Has vite, vitest, oxlint, oxfmt\n├── vite.config.ts            # Vite config\n├── vitest.config.ts          # Vitest config\n├── .oxlintrc                 # Oxlint config\n├── .oxfmtrc                  # Oxfmt config\n└── src/\n```\n\n**After:**\n\n```\nmy-package/\n├── package.json              # Only has vite-plus\n├── vitest.config.ts          # Vitest config\n├── vite.config.ts            # Unified config (all merged)\n└── src/\n```\n\n**vite.config.ts (after migration):**\n\n```typescript\n// Import from 'vite' still works - overrides maps it to vite-plus\nimport react from '@vitejs/plugin-react';\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  // Vite configuration\n  plugins: [react()],\n  server: {\n    port: 3000,\n  },\n  build: {\n    target: 'esnext',\n  },\n\n  // lint configuration (merged from .oxlintrc)\n  lint: {\n    rules: {\n      'no-unused-vars': 'error',\n      'no-console': 'warn',\n    },\n    ignorePatterns: ['dist', 'node_modules'],\n  },\n\n  // format configuration (merged from .oxfmtrc)\n  fmt: {\n    printWidth: 100,\n    tabWidth: 2,\n    semi: true,\n    singleQuote: true,\n    trailingComma: 'es5',\n  },\n});\n```\n\n**vitest.config.ts (after migration):**\n\n```typescript\nimport { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n  },\n});\n```\n\n## Monorepo Configuration Migration\n\n### for pnpm\n\n`pnpm-workspace.yaml`\n\n```yaml\ncatalog:\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n\noverrides:\n  vite: 'catalog:'\n  vitest: 'catalog:'\n\npeerDependencyRules:\n  allowAny:\n    - vite\n    - vitest\n  allowedVersions:\n    vite: '*'\n    vitest: '*'\n```\n\n### for npm\n\n`package.json`\n\n```json\n{\n  \"devDependencies\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  },\n  \"overrides\": {\n    \"vite\": \"npm:@voidzero-dev/vite-plus-core@latest\",\n    \"vitest\": \"npm:@voidzero-dev/vite-plus-test@latest\"\n  }\n}\n```\n\n### for yarn 4.10.0+ (need catalog support)\n\n`.yarnrc.yml`\n\n```yaml\ncatalog:\n  vite: npm:@voidzero-dev/vite-plus-core@latest\n  vitest: npm:@voidzero-dev/vite-plus-test@latest\n```\n\n`package.json`\n\n```json\n{\n  \"resolutions\": {\n    \"vite\": \"catalog:\",\n    \"vitest\": \"catalog:\"\n  }\n}\n```\n\n### for yarn v1(not supported yet)\n\nTODO: Add support for yarn v1\n\n## Success Criteria\n\nA successful migration should:\n\n1. ✅ Replace all standalone tool dependencies with vite-plus\n2. ✅ **Add package.json overrides** to force vite → vite-plus (for transitive deps)\n3. ✅ **Transform vitest imports** to vite/test (since vitest is removed)\n4. ✅ Merge all configurations into vite.config.ts\n5. ✅ Preserve all user customizations and settings\n6. ✅ Remove redundant configuration files\n7. ✅ Provide clear feedback and next steps\n8. ✅ Handle monorepo migrations efficiently\n9. ✅ Be safe and transparent about what changes\n\n## ESLint Migration\n\nWhen an ESLint flat config (`eslint.config.{js,mjs,cjs,ts,mts,cts}`) and `eslint` dependency are detected, `vp migrate` offers to convert the ESLint configuration to oxlint using [`@oxlint/migrate`](https://www.npmjs.com/package/@oxlint/migrate).\n\n**Flow**: ESLint → oxlint (via `@oxlint/migrate`) → vite+ (existing merge flow)\n\n**Steps**:\n\n1. Run `vpx @oxlint/migrate --merge --type-aware --with-nursery --details` to generate `.oxlintrc.json`\n2. Run `vpx @oxlint/migrate --replace-eslint-comments` to replace `eslint-disable` comments\n3. Delete the ESLint config file\n4. Remove `eslint` from `devDependencies`\n5. Rewrite `eslint` scripts in `package.json` to `vp lint`, stripping ESLint-only flags\n6. Rewrite `eslint` references in lint-staged configs (package.json `lint-staged` field and standalone config files like `.lintstagedrc.json`)\n7. The existing migration flow picks up `.oxlintrc.json` and merges it into `vite.config.ts`\n\n**Script Rewriting** (powered by [brush-parser](https://github.com/reubeno/brush) for shell AST parsing):\n\n| Before                                     | After                                        |\n| ------------------------------------------ | -------------------------------------------- |\n| `eslint .`                                 | `vp lint .`                                  |\n| `eslint --cache --ext .ts --fix .`         | `vp lint --fix .`                            |\n| `NODE_ENV=test eslint --cache .`           | `NODE_ENV=test vp lint .`                    |\n| `cross-env NODE_ENV=test eslint --cache .` | `cross-env NODE_ENV=test vp lint .`          |\n| `eslint . && vite build`                   | `vp lint . && vite build`                    |\n| `if [ -f .eslintrc ]; then eslint .; fi`   | `if [ -f .eslintrc ]; then vp lint . fi`     |\n| `npx eslint .`                             | `npx eslint .` (npx/bunx wrappers preserved) |\n\nStripped ESLint-only flags: `--cache`, `--ext`, `--parser`, `--parser-options`, `--plugin`, `--rulesdir`, `--resolve-plugins-relative-to`, `--output-file`, `--env`, `--no-eslintrc`, `--no-error-on-unmatched-pattern`, `--debug`, `--no-inline-config`\n\nThe rewriter handles:\n\n- **Compound commands**: `&&`, `||`, `|`, `if/then/fi`, `while/do/done`, `for`, `case`, brace groups `{ ...; }`, subshells `(...)`\n- **Environment variable prefixes**: `NODE_ENV=test eslint .`\n- **cross-env wrappers**: `cross-env NODE_ENV=test eslint .`\n- **No-op safety**: Scripts without `eslint` are returned unchanged (no formatting corruption from AST round-tripping)\n\n**Legacy ESLint Config Handling**:\n\nIf only a legacy ESLint config (`.eslintrc*`) is detected without a flat config (`eslint.config.*`), the migration warns and skips ESLint migration. The warning guides users to upgrade to ESLint v9 first, since `@oxlint/migrate` only supports flat configs:\n\n> Legacy ESLint configuration detected (.eslintrc). Automatic migration to Oxlint requires ESLint v9+ with flat config format (eslint.config.\\*). Please upgrade to ESLint v9 first: https://eslint.org/docs/latest/use/migrate-to-9.0.0\n\n**Behavior**:\n\n- Interactive mode: prompts user for confirmation upfront (Phase 1), executes later (Phase 2)\n- Non-interactive mode: auto-runs without prompting\n- Failure is non-blocking — warns and continues with the rest of migration\n- Re-runnable: if user declines initially, running `vp migrate` again offers eslint migration\n\n## Prettier Migration\n\nWhen a Prettier configuration file (`.prettierrc*`, `prettier.config.*`, or `\"prettier\"` key in package.json) and `prettier` dependency are detected, `vp migrate` offers to convert the Prettier configuration to oxfmt using `vp fmt --migrate=prettier`.\n\n**Flow**: Prettier → oxfmt (via `vp fmt --migrate=prettier`) → vite+ (existing merge flow)\n\n**Steps**:\n\n1. Run `vp fmt --migrate=prettier` to generate `.oxfmtrc.json` from Prettier config (if a standalone config file exists, not `package.json#prettier`)\n2. Delete all Prettier config files (`.prettierrc*`, `prettier.config.*`)\n3. Remove `\"prettier\"` key from package.json if present\n4. Remove `prettier` and `prettier-plugin-*` from `devDependencies`/`dependencies`\n5. Rewrite `prettier` scripts in `package.json` to `vp fmt`, stripping Prettier-only flags\n6. Rewrite `prettier` references in lint-staged configs\n7. Warn about `.prettierignore` if present (Oxfmt uses `.oxfmtignore`)\n8. The existing migration flow picks up `.oxfmtrc.json` and merges it into `vite.config.ts`\n\n**Script Rewriting** (powered by [brush-parser](https://github.com/reubeno/brush) for shell AST parsing):\n\n| Before                                            | After                                                  |\n| ------------------------------------------------- | ------------------------------------------------------ |\n| `prettier .`                                      | `vp fmt .`                                             |\n| `prettier --write .`                              | `vp fmt .`                                             |\n| `prettier --check .`                              | `vp fmt --check .`                                     |\n| `prettier --list-different .`                     | `vp fmt --check .`                                     |\n| `prettier -l .`                                   | `vp fmt --check .`                                     |\n| `prettier --write --single-quote --tab-width 4 .` | `vp fmt .`                                             |\n| `prettier --config .prettierrc --write .`         | `vp fmt .`                                             |\n| `prettier --plugin prettier-plugin-tailwindcss .` | `vp fmt .`                                             |\n| `cross-env NODE_ENV=test prettier --write .`      | `cross-env NODE_ENV=test vp fmt .`                     |\n| `prettier --write . && eslint --fix .`            | `vp fmt . && eslint --fix .`                           |\n| `npx prettier --write .`                          | `npx prettier --write .` (npx/bunx wrappers preserved) |\n\n**Stripped Prettier-only flags**:\n\n- Value flags: `--config`, `--ignore-path`, `--plugin`, `--parser`, `--cache-location`, `--cache-strategy`, `--log-level`, `--stdin-filepath`, `--cursor-offset`, `--range-start`, `--range-end`, `--config-precedence`, `--tab-width`, `--print-width`, `--trailing-comma`, `--arrow-parens`, `--prose-wrap`, `--end-of-line`, `--html-whitespace-sensitivity`, `--quote-props`, `--embedded-language-formatting`, `--experimental-ternaries`\n- Boolean flags: `--write`, `--cache`, `--no-config`, `--no-editorconfig`, `--with-node-modules`, `--require-pragma`, `--insert-pragma`, `--no-bracket-spacing`, `--single-quote`, `--no-semi`, `--jsx-single-quote`, `--bracket-same-line`, `--use-tabs`, `--debug-check`, `--debug-print-doc`, `--debug-benchmark`, `--debug-repeat`\n\n**Converted flags**: `--list-different` / `-l` → `--check`\n\n**Kept flags**: `--check`, `--fix`, `--no-error-on-unmatched-pattern`, positional args (file paths/globs)\n\n**Behavior**:\n\n- Interactive mode: prompts user for confirmation upfront (Phase 1), executes later (Phase 2)\n- Non-interactive mode: auto-runs without prompting\n- Failure is non-blocking — warns and continues with the rest of migration\n- Re-runnable: if user declines initially, running `vp migrate` again offers prettier migration\n\n## References\n\n### Code Transformation\n\n- [ast-grep](https://ast-grep.github.io/) - Structural search and replace tool\n- [Turborepo Codemods](https://turborepo.com/docs/reference/turbo-codemod) - Similar migration approach\n- [jscodeshift](https://github.com/facebook/jscodeshift) - Alternative AST transformation tool\n\n### Tools\n\n- [@ast-grep/napi](https://www.npmjs.com/package/@ast-grep/napi) - Node.js bindings for ast-grep\n- [@oxlint/migrate](https://www.npmjs.com/package/@oxlint/migrate) - ESLint to oxlint migration tool\n- [brush-parser](https://github.com/reubeno/brush) - Shell AST parser for script rewriting (Rust)\n- [@clack/prompts](https://www.npmjs.com/package/@clack/prompts) - Beautiful CLI prompts\n- [typescript](https://www.typescriptlang.org/) - For parsing TypeScript configs\n\n### Inspiration\n\n- [Vue 2 to Vue 3 Migration](https://v3-migration.vuejs.org/) - Similar migration tool\n- [React Codemod](https://github.com/reactjs/react-codemod) - React migration scripts\n- [Angular Update Guide](https://update.angular.io/) - Automated Angular migrations\n"
  },
  {
    "path": "rfcs/outdated-package-command.md",
    "content": "# RFC: Vite+ Outdated Package Command\n\n## Summary\n\nAdd `vite outdated` command that automatically adapts to the detected package manager (pnpm/npm/yarn) for checking outdated packages. This helps developers identify packages that have newer versions available, maintain up-to-date dependencies, and manage security vulnerabilities by showing which packages can be updated.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands to check for outdated packages:\n\n```bash\npnpm outdated [<pattern>...]\nnpm outdated [[@scope/]<package>...]\nyarn outdated [<package>...]\n```\n\nThis creates friction in dependency management workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify dependency updates**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm outdated                         # pnpm project\nnpm outdated                          # npm project\nyarn outdated                         # yarn project\n\n# Different output formats\npnpm outdated --format json           # pnpm - JSON output\nnpm outdated --json                   # npm - JSON output\nyarn outdated                         # yarn - table format (no JSON in v1)\n\n# Different workspace targeting\npnpm outdated --filter app            # pnpm - filter workspaces\nnpm outdated --workspace app          # npm - specify workspace\nyarn outdated                         # yarn - no workspace filtering in v1\n\n# Different dependency type filtering\npnpm outdated --prod                  # pnpm - only production deps\nnpm outdated                          # npm - no filtering option\nyarn outdated                         # yarn - no filtering option\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvite outdated                         # Check all packages\nvite outdated <package>               # Check specific packages\n\n# Output formats\nvite outdated --format json           # JSON output (maps to pnpm --format json, npm --json, yarn --json)\nvite outdated --format list           # List output (maps to pnpm --format list, npm --parseable)\nvite outdated --format table          # Table format (default)\nvite outdated --long                  # Verbose output\n\n# Workspace operations\nvite outdated --filter app            # Check in specific workspace (maps to pnpm --filter, npm --workspace)\nvite outdated -r                      # Check recursively across workspaces (maps to pnpm -r, npm --all)\nvite outdated -w                      # Include workspace root (pnpm)\nvite outdated -w -r                   # Include workspace root and check recursively (pnpm)\n\n# Dependency type filtering\nvite outdated -P                      # Only production dependencies (pnpm)\nvite outdated --prod                  # Only production dependencies (pnpm)\nvite outdated -D                      # Only dev dependencies (pnpm)\nvite outdated --dev                   # Only dev dependencies (pnpm)\nvite outdated --compatible            # Only versions satisfying package.json (pnpm)\n\n# Sorting and filtering\nvite outdated --sort-by name          # Sort results by name (pnpm)\nvite outdated --no-optional           # Exclude optional dependencies (pnpm)\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n```bash\nvite outdated [PACKAGE...] [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Basic usage\nvite outdated\nvite outdated react\nvite outdated \"*gulp-*\" @babel/core\n\n# Output formats\nvite outdated --format json           # JSON output\nvite outdated --format list           # List output\nvite outdated --long                  # Verbose output\n\n# Workspace operations\nvite outdated -r                      # Recursive across all workspaces\nvite outdated --recursive             # Recursive across all workspaces\nvite outdated --filter app            # Check in specific workspace\nvite outdated -w                      # Include workspace root (pnpm)\nvite outdated -w -r                   # Include workspace root and check recursively (pnpm)\n\n# Dependency type filtering\nvite outdated -P                      # Only production dependencies (pnpm)\nvite outdated --prod                  # Only production dependencies (pnpm)\nvite outdated -D                      # Only dev dependencies (pnpm)\nvite outdated --dev                   # Only dev dependencies (pnpm)\nvite outdated --no-optional           # Exclude optional dependencies (pnpm)\nvite outdated --compatible            # Only compatible versions (pnpm)\n\n# Sorting\nvite outdated --sort-by name          # Sort results by name (pnpm)\n\n# Global packages\nvite outdated -g                      # Check globally installed packages\n```\n\n### Global packages checking\n\nOnly use `npm` to check globally installed packages, because `vp install -g` uses `npm` cli to install global packages.\n\n```bash\nvite outdated -g                      # Check globally installed packages\n\n-> npm outdated -g\n```\n\n### Command Mapping\n\n**pnpm references:**\n\n- https://pnpm.io/cli/outdated\n- Checks for outdated packages with pattern support\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-outdated\n- Lists outdated packages\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/outdated (yarn@1)\n- https://yarnpkg.com/cli/upgrade-interactive (yarn@2+)\n- Checks for outdated package dependencies\n\n| Vite+ Flag             | pnpm                   | npm                                 | yarn@1          | yarn@2+                    | Description                                   |\n| ---------------------- | ---------------------- | ----------------------------------- | --------------- | -------------------------- | --------------------------------------------- |\n| `vite outdated`        | `pnpm outdated`        | `npm outdated`                      | `yarn outdated` | `yarn upgrade-interactive` | Check for outdated packages                   |\n| `<pattern>...`         | `<pattern>...`         | `[[@scope/]<pkg>]`                  | `[<package>]`   | N/A                        | Package patterns to check                     |\n| `--long`               | `--long`               | `--long`                            | N/A             | N/A                        | Extended output format                        |\n| `--format <format>`    | `--format <format>`    | json: `--json`/ list: `--parseable` | `--json`        | N/A                        | Output format (table/list/json)               |\n| `-r, --recursive`      | `-r, --recursive`      | `--all`                             | N/A             | N/A                        | Check across all workspaces                   |\n| `--filter <pattern>`   | `--filter <pattern>`   | `--workspace <pattern>`             | N/A             | N/A                        | Target specific workspace                     |\n| `-w, --workspace-root` | `-w, --workspace-root` | `--include-workspace-root`          | N/A             | N/A                        | Include workspace root                        |\n| `-P, --prod`           | `-P, --prod`           | N/A                                 | N/A             | N/A                        | Only production dependencies (pnpm-specific)  |\n| `-D, --dev`            | `-D, --dev`            | N/A                                 | N/A             | N/A                        | Only dev dependencies (pnpm-specific)         |\n| `--no-optional`        | `--no-optional`        | N/A                                 | N/A             | N/A                        | Exclude optional dependencies (pnpm-specific) |\n| `--compatible`         | `--compatible`         | N/A                                 | N/A             | N/A                        | Only show compatible versions (pnpm-specific) |\n| `--sort-by <field>`    | `--sort-by <field>`    | N/A                                 | N/A             | N/A                        | Sort results by field (pnpm-specific)         |\n| `-g, --global`         | `-g, --global`         | `-g, --global`                      | N/A             | N/A                        | Check globally installed packages             |\n\n**Note:**\n\n- pnpm supports pattern matching for selective package checking\n- npm accepts package names but not glob patterns\n- yarn@1 accepts package names but limited filtering options\n- yarn@2+ uses interactive mode (`upgrade-interactive`) instead of traditional `outdated`\n- pnpm has the most comprehensive filtering and output options\n\n### Outdated Behavior Differences Across Package Managers\n\n#### pnpm\n\n**Outdated behavior:**\n\n- Checks for outdated packages with pattern support\n- Supports glob patterns: `pnpm outdated \"*gulp-*\" @babel/core`\n- Shows current, wanted, and latest versions\n- Supports workspace filtering with `--filter`\n- Can filter by dependency type (prod, dev, optional)\n- Multiple output formats (table, list, json)\n- Shows only compatible versions with `--compatible`\n\n**Output format:**\n\n```\nPackage         Current  Wanted  Latest\nreact           18.2.0   18.3.1  18.3.1\nlodash          4.17.20  4.17.21 4.17.21\n@babel/core     7.20.0   7.20.12 7.25.8\n```\n\n**Options:**\n\n- `--format`: Output format (table, list, json)\n- `--long`: Extended information\n- `-r`: Recursive across workspaces\n- `--filter`: Workspace filtering\n- `--prod`/`--dev`: Dependency type filtering\n- `--compatible`: Only compatible versions\n- `--sort-by`: Sort results by field\n- `--no-optional`: Exclude optional dependencies\n\n#### npm\n\n**Outdated behavior:**\n\n- Lists outdated packages\n- Shows current, wanted, latest, location, and depended by\n- Supports workspace targeting with `--workspace`\n- Can show all dependencies with `--all` (including transitive)\n- JSON and parseable output available\n- Color-coded output (red = should update, yellow = major version)\n\n**Output format:**\n\n```\nPackage         Current  Wanted  Latest  Location             Depended by\nreact           18.2.0   18.3.1  18.3.1  node_modules/react   my-app\nlodash          4.17.20  4.17.21 4.17.21 node_modules/lodash  my-app\n```\n\n**Options:**\n\n- `--json`: JSON format\n- `--long`: Extended information (shows package type)\n- `--parseable`: Parseable format\n- `--all`: Show all outdated packages including transitive\n- `--workspace`: Target specific workspace\n\n#### yarn@1 (Classic)\n\n**Outdated behavior:**\n\n- Checks for outdated package dependencies\n- Shows package name, current, wanted, latest, package type, and URL\n- Simple table output\n- Can check specific packages\n- No JSON output support\n- No workspace filtering\n\n**Output format:**\n\n```\nPackage         Current  Wanted  Latest  Package Type  URL\nreact           18.2.0   18.3.1  18.3.1  dependencies  https://...\nlodash          4.17.20  4.17.21 4.17.21 dependencies  https://...\n```\n\n**Options:**\n\n- No command-line options for filtering or formatting\n- Accepts package names as arguments\n\n#### yarn@2+ (Berry)\n\n**Outdated behavior:**\n\n- Uses `yarn upgrade-interactive` instead of `outdated`\n- Opens fullscreen terminal interface\n- Shows out-of-date packages with status comparison\n- Allows selective upgrading\n- Different paradigm from traditional `outdated` command\n\n**Output format:**\n\nInteractive terminal UI showing:\n\n- Package names\n- Current versions\n- Available versions\n- Selection checkboxes\n\n**Options:**\n\n- Interactive mode only\n- `yarn upgrade-interactive` for checking and upgrading\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command variant:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Check for outdated packages\n    #[command(disable_help_flag = true)]\n    Outdated {\n        /// Package name(s) to check (supports glob patterns in pnpm)\n        #[arg(value_name = \"PACKAGE\")]\n        packages: Vec<String>,\n\n        /// Show extended information\n        #[arg(long)]\n        long: bool,\n\n        /// Output format: table (default), list, or json\n        /// Maps to: pnpm: --format <format>, npm: --json/--parseable, yarn@1: --json\n        #[arg(long, value_name = \"FORMAT\")]\n        format: Option<String>,\n\n        /// Check recursively across all workspaces\n        /// Maps to: pnpm: -r, npm: --all\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        /// Maps to: pnpm: --filter <pattern>, npm: --workspace <pattern>\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Vec<String>,\n\n        /// Include workspace root\n        /// Maps to: pnpm: -w/--workspace-root, npm: --include-workspace-root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Only production and optional dependencies (pnpm-specific)\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only dev dependencies (pnpm-specific)\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Exclude optional dependencies (pnpm-specific)\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Only show compatible versions (pnpm-specific)\n        #[arg(long)]\n        compatible: bool,\n\n        /// Sort results by field (pnpm-specific)\n        #[arg(long, value_name = \"FIELD\")]\n        sort_by: Option<String>,\n\n        /// Check globally installed packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/commands/outdated.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\n#[derive(Debug, Default)]\npub struct OutdatedCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub long: bool,\n    pub format: Option<&'a str>,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub prod: bool,\n    pub dev: bool,\n    pub no_optional: bool,\n    pub compatible: bool,\n    pub sort_by: Option<&'a str>,\n    pub global: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the outdated command with the package manager.\n    #[must_use]\n    pub async fn run_outdated_command(\n        &self,\n        options: &OutdatedCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_outdated_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the outdated command.\n    #[must_use]\n    pub fn resolve_outdated_command(&self, options: &OutdatedCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        // Global packages should use npm cli only\n        if options.global {\n            bin_name = \"npm\".into();\n            args.push(\"outdated\".into());\n            args.push(\"-g\".into());\n            args.extend_from_slice(options.packages);\n            if let Some(pass_through_args) = options.pass_through_args {\n                args.extend_from_slice(pass_through_args);\n            }\n            return ResolveCommandResult { bin_path: bin_name, args, envs };\n        }\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                args.push(\"outdated\".into());\n\n                // Handle format option\n                if let Some(format) = options.format {\n                    args.push(\"--format\".into());\n                    args.push(format.into());\n                }\n\n                if options.long {\n                    args.push(\"--long\".into());\n                }\n\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n\n                if options.compatible {\n                    args.push(\"--compatible\".into());\n                }\n\n                if let Some(sort_by) = options.sort_by {\n                    args.push(\"--sort-by\".into());\n                    args.push(sort_by.into());\n                }\n\n                if options.global {\n                    args.push(\"--global\".into());\n                }\n\n                // Add packages (pnpm supports glob patterns)\n                args.extend_from_slice(options.packages);\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n\n                // Check if yarn@2+ (uses upgrade-interactive)\n                if !self.version.starts_with(\"1.\") {\n                    println!(\"Note: yarn@2+ uses 'yarn upgrade-interactive' for checking outdated packages\");\n                    args.push(\"upgrade-interactive\".into());\n\n                    // Warn about unsupported flags\n                    if options.format.is_some() {\n                        println!(\"Warning: --format not supported by yarn@2+\");\n                    }\n                } else {\n                    // yarn@1\n                    args.push(\"outdated\".into());\n\n                    // Add packages (yarn@1 supports package names)\n                    args.extend_from_slice(options.packages);\n\n                    // yarn@1 supports --json format\n                    if let Some(format) = options.format {\n                        if format == \"json\" {\n                            args.push(\"--json\".into());\n                        } else {\n                            println!(\"Warning: yarn@1 only supports json format, not {}\", format);\n                        }\n                    }\n                }\n\n                // Common warnings\n                if options.long {\n                    println!(\"Warning: --long not supported by yarn\");\n                }\n                if options.workspace_root {\n                    println!(\"Warning: --workspace-root not supported by yarn\");\n                }\n                if options.recursive {\n                    println!(\"Warning: --recursive not supported by yarn\");\n                }\n                if let Some(filters) = options.filters {\n                    if !filters.is_empty() {\n                        println!(\"Warning: --filter not supported by yarn\");\n                    }\n                }\n                if options.prod || options.dev {\n                    println!(\"Warning: --prod/--dev not supported by yarn\");\n                }\n                if options.no_optional {\n                    println!(\"Warning: --no-optional not supported by yarn\");\n                }\n                if options.compatible {\n                    println!(\"Warning: --compatible not supported by yarn\");\n                }\n                if options.sort_by.is_some() {\n                    println!(\"Warning: --sort-by not supported by yarn\");\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"outdated\".into());\n\n                // npm format flags - translate from --format\n                if let Some(format) = options.format {\n                    match format {\n                        \"json\" => args.push(\"--json\".into()),\n                        \"list\" => args.push(\"--parseable\".into()),\n                        \"table\" => {}, // Default, no flag needed\n                        _ => println!(\"Warning: npm only supports formats: json, list, table\"),\n                    }\n                }\n\n                if options.long {\n                    args.push(\"--long\".into());\n                }\n\n                // npm workspace flags - translate from --filter\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                // npm uses --include-workspace-root when workspace_root is set\n                if options.workspace_root {\n                    args.push(\"--include-workspace-root\".into());\n                }\n\n                // npm --all translates from -r/--recursive\n                if options.recursive {\n                    args.push(\"--all\".into());\n                }\n\n                if options.global {\n                    args.push(\"--global\".into());\n                }\n\n                // Add packages (npm supports package names)\n                args.extend_from_slice(options.packages);\n\n                // Warn about pnpm-specific flags\n                if options.prod || options.dev {\n                    println!(\"Warning: --prod/--dev not supported by npm\");\n                }\n                if options.no_optional {\n                    println!(\"Warning: --no-optional not supported by npm\");\n                }\n                if options.compatible {\n                    println!(\"Warning: --compatible not supported by npm\");\n                }\n                if options.sort_by.is_some() {\n                    println!(\"Warning: --sort-by not supported by npm\");\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n```\n\n**File**: `crates/vite_package_manager/src/commands/mod.rs`\n\nUpdate to include outdated module:\n\n```rust\npub mod add;\nmod install;\npub mod remove;\npub mod update;\npub mod link;\npub mod unlink;\npub mod dedupe;\npub mod why;\npub mod outdated;  // Add this line\n```\n\n#### 3. Outdated Command Implementation\n\n**File**: `crates/vite_task/src/outdated.rs` (new file)\n\n```rust\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_package_manager::{\n    PackageManager,\n    commands::outdated::OutdatedCommandOptions,\n};\nuse vite_workspace::Workspace;\n\npub struct OutdatedCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl OutdatedCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        packages: Vec<String>,\n        long: bool,\n        format: Option<String>,\n        recursive: bool,\n        filters: Vec<String>,\n        prod: bool,\n        dev: bool,\n        no_optional: bool,\n        compatible: bool,\n        sort_by: Option<String>,\n        global: bool,\n        extra_args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        // Build outdated command options\n        let outdated_options = OutdatedCommandOptions {\n            packages: &packages,\n            long,\n            format: format.as_deref(),\n            recursive,\n            filters: if filters.is_empty() { None } else { Some(&filters) },\n            prod,\n            dev,\n            no_optional,\n            compatible,\n            sort_by: sort_by.as_deref(),\n            global,\n            pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) },\n        };\n\n        let exit_status = package_manager\n            .run_outdated_command(&outdated_options, &workspace.root)\n            .await?;\n\n        // Note: outdated command may exit with code 1 if outdated packages are found\n        // This is expected behavior, not an error\n        if !exit_status.success() {\n            let exit_code = exit_status.code();\n            // Exit code 1 typically means outdated packages found, which is OK\n            if exit_code != Some(1) {\n                return Err(Error::CommandFailed {\n                    command: \"outdated\".to_string(),\n                    exit_code,\n                });\n            }\n        }\n\n        workspace.unload().await?;\n\n        Ok(ExecutionSummary::default())\n    }\n}\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache outdated operations.\n\n**Rationale**:\n\n- `outdated` queries remote registry for latest versions\n- Results change frequently as new versions are published\n- Caching would provide stale information\n- Users expect fresh data when checking for updates\n\n### 2. Pattern Support\n\n**Decision**: Accept patterns but warn when package manager doesn't support glob patterns.\n\n**Rationale**:\n\n- pnpm supports glob patterns: `pnpm outdated \"*gulp-*\" @babel/core`\n- npm and yarn accept package names but not glob patterns\n- Warn users about limited pattern support\n- Better UX than erroring\n\n### 3. Exit Code Handling\n\n**Decision**: Don't treat exit code 1 as an error for outdated command.\n\n**Rationale**:\n\n- Package managers return exit code 1 when outdated packages are found\n- This is expected behavior, not a failure\n- Only treat other exit codes as errors\n- Matches package manager semantics\n\n### 4. Output Format Support\n\n**Decision**: Support pnpm's `--format` flag and npm's `--json`/`--parseable` flags.\n\n**Rationale**:\n\n- pnpm has `--format` with table/list/json options\n- npm has separate `--json` and `--parseable` flags\n- yarn@1 has fixed table output\n- yarn@2+ uses interactive mode\n- Translate flags appropriately per package manager\n\n### 5. Workspace Filtering\n\n**Decision**: Support both pnpm's `--filter` and npm's `--workspace` patterns.\n\n**Rationale**:\n\n- Different package managers use different flags\n- Translate flags appropriately\n- Warn when flag not supported\n- Consistent with other Vite+ commands\n\n### 6. Dependency Type Filtering\n\n**Decision**: Support pnpm's `--prod`, `--dev`, `--no-optional` flags with warnings.\n\n**Rationale**:\n\n- pnpm allows filtering by dependency type\n- Not available in npm or yarn\n- Useful for focused updates\n- Warn when not supported\n\n### 7. Yarn@2+ Behavior\n\n**Decision**: Use `upgrade-interactive` for yarn@2+ instead of `outdated`.\n\n**Rationale**:\n\n- yarn@2+ recommends `upgrade-interactive` for checking updates\n- Provides interactive UI instead of simple table\n- Different paradigm but achieves same goal\n- Inform users about different behavior\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vite outdated\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### Invalid Format Option\n\n```bash\n$ vite outdated --format invalid\nError: Invalid format 'invalid'\nValid formats: table, list, json\n```\n\n### Unsupported Flag Warning\n\n```bash\n$ vite outdated --prod\nDetected package manager: npm@11.0.0\nWarning: --prod not supported by npm\nRunning: npm outdated\n```\n\n## User Experience\n\n### Success Output (pnpm)\n\n```bash\n$ vite outdated\nDetected package manager: pnpm@10.15.0\nRunning: pnpm outdated\n\nPackage         Current  Wanted  Latest\nreact           18.2.0   18.3.1  18.3.1\nlodash          4.17.20  4.17.21 4.17.21\n@babel/core     7.20.0   7.20.12 7.25.8\n\nDone in 1.2s\n```\n\n### Success Output (npm)\n\n```bash\n$ vite outdated\nDetected package manager: npm@11.0.0\nRunning: npm outdated\n\nPackage         Current  Wanted  Latest  Location             Depended by\nreact           18.2.0   18.3.1  18.3.1  node_modules/react   my-app\nlodash          4.17.20  4.17.21 4.17.21 node_modules/lodash  my-app\n\nDone in 0.8s\n```\n\n### Success Output (yarn@1)\n\n```bash\n$ vite outdated\nDetected package manager: yarn@1.22.19\nRunning: yarn outdated\n\nPackage         Current  Wanted  Latest  Package Type  URL\nreact           18.2.0   18.3.1  18.3.1  dependencies  https://...\nlodash          4.17.20  4.17.21 4.17.21 dependencies  https://...\n\nDone in 1.0s\n```\n\n### JSON Output (pnpm)\n\n```bash\n$ vite outdated --format json\nDetected package manager: pnpm@10.15.0\nRunning: pnpm outdated --format json\n\n[\n  {\n    \"packageName\": \"react\",\n    \"current\": \"18.2.0\",\n    \"wanted\": \"18.3.1\",\n    \"latest\": \"18.3.1\",\n    \"dependencyType\": \"dependencies\"\n  },\n  {\n    \"packageName\": \"lodash\",\n    \"current\": \"4.17.20\",\n    \"wanted\": \"4.17.21\",\n    \"latest\": \"4.17.21\",\n    \"dependencyType\": \"dependencies\"\n  }\n]\n\nDone in 1.1s\n```\n\n### Pattern Matching (pnpm)\n\n```bash\n$ vite outdated \"*babel*\" \"eslint-*\"\nDetected package manager: pnpm@10.15.0\nRunning: pnpm outdated \"*babel*\" \"eslint-*\"\n\nPackage              Current  Wanted   Latest\n@babel/core          7.20.0   7.20.12  7.25.8\n@babel/preset-env    7.20.0   7.20.12  7.25.8\neslint-config-next   13.0.0   13.0.7   14.2.5\neslint-plugin-react  7.32.0   7.32.2   7.37.2\n\nDone in 1.3s\n```\n\n### Workspace Filtering (pnpm)\n\n```bash\n$ vite outdated --filter app -r\nDetected package manager: pnpm@10.15.0\nRunning: pnpm --filter app outdated --recursive\n\nScope: app\n\nPackage         Current  Wanted  Latest\nreact           18.2.0   18.3.1  18.3.1\nreact-dom       18.2.0   18.3.1  18.3.1\n\nDone in 1.0s\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Always Error on Exit Code 1\n\n```bash\nvite outdated\n# Exit code 1 when outdated packages found\n# Treat as error\n```\n\n**Rejected because**:\n\n- Outdated packages found is normal, not an error\n- Would break CI/CD workflows\n- Matches package manager behavior\n- Users expect exit code 1 to indicate packages need updating\n\n### Alternative 2: Custom Output Format\n\n```bash\nvite outdated --format vite\n# Custom unified format across all package managers\n```\n\n**Rejected because**:\n\n- Output format parsing is fragile\n- Different package managers provide different data\n- Better to pass through native output\n- Let users see familiar format from their package manager\n\n### Alternative 3: Auto-Update Option\n\n```bash\nvp outdated --update\n# Automatically update all outdated packages\n```\n\n**Rejected because**:\n\n- Mixing check and update is dangerous\n- Users should review before updating\n- Separate `vp update` command exists\n- Keep commands focused on single purpose\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Outdated` command variant to `Commands` enum\n2. Create `outdated.rs` module in both crates\n3. Implement package manager command resolution\n4. Handle exit code 1 as success case\n5. Add basic error handling\n\n### Phase 2: Advanced Features\n\n1. Implement output format options (json, table, list, parseable)\n2. Add workspace filtering support\n3. Implement dependency type filtering (prod, dev)\n4. Add pattern matching support\n5. Handle yarn@2+ interactive mode\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Test pattern matching (pnpm)\n3. Test workspace operations\n4. Test output format options\n5. Test exit code handling\n6. Integration tests with mock package managers\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document package manager compatibility\n4. Add troubleshooting guide\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x\n- pnpm@10.x\n- yarn@1.x\n- yarn@4.x\n- npm@10.x\n- npm@11.x\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_outdated_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\"]);\n}\n\n#[test]\nfn test_pnpm_outdated_with_packages() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        packages: &[\"*babel*\".to_string(), \"eslint-*\".to_string()],\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\", \"*babel*\", \"eslint-*\"]);\n}\n\n#[test]\nfn test_pnpm_outdated_json() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        format: Some(\"json\"),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\", \"--format\", \"json\"]);\n}\n\n#[test]\nfn test_npm_outdated_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\"]);\n}\n\n#[test]\nfn test_npm_outdated_json() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        format: Some(\"json\"),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\", \"--json\"]);\n}\n\n#[test]\nfn test_yarn_outdated_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\"]);\n}\n\n#[test]\nfn test_pnpm_outdated_with_filter() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        filters: Some(&[\"app\".to_string()]),\n        recursive: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"--filter\", \"app\", \"outdated\", \"--recursive\"]);\n}\n\n#[test]\nfn test_pnpm_outdated_prod_only() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_outdated_command(&OutdatedCommandOptions {\n        prod: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"outdated\", \"--prod\"]);\n}\n```\n\n### Integration Tests\n\nCreate fixtures for testing with each package manager:\n\n```\nfixtures/outdated-test/\n  pnpm-workspace.yaml\n  package.json (with some outdated deps)\n  packages/\n    app/\n      package.json (with outdated deps)\n    utils/\n      package.json (with outdated deps)\n  test-steps.json\n```\n\nTest cases:\n\n1. Basic outdated check\n2. Pattern matching (pnpm only)\n3. JSON output\n4. Workspace-specific outdated\n5. Recursive workspace checking\n6. Dependency type filtering\n7. Compatible versions only\n8. Global package checking\n9. Warning messages for unsupported flags\n10. Exit code 1 handling (outdated found)\n\n## CLI Help Output\n\n```bash\n$ vite outdated --help\nCheck for outdated packages\n\nUsage: vite outdated [PACKAGE]... [OPTIONS]\n\nArguments:\n  [PACKAGE]...           Package name(s) to check (pnpm supports glob patterns)\n\nOptions:\n  --long                 Show extended information\n  --format <FORMAT>      Output format: table, list, or json\n                         Maps to: pnpm: --format <format>, npm: --json/--parseable, yarn@1: --json\n  -r, --recursive        Check recursively across all workspaces\n                         Maps to: pnpm: -r, npm: --all\n  --filter <PATTERN>     Filter packages in monorepo (can be used multiple times)\n                         Maps to: pnpm: --filter <pattern>, npm: --workspace <pattern>\n  -w, --workspace-root   Include workspace root\n                         Maps to: pnpm: -w/--workspace-root, npm: --include-workspace-root\n  -P, --prod             Only production and optional dependencies (pnpm only)\n  -D, --dev              Only dev dependencies (pnpm only)\n  --no-optional          Exclude optional dependencies (pnpm only)\n  --compatible           Only show compatible versions (pnpm only)\n  --sort-by <FIELD>      Sort results by field (pnpm only, supports 'name')\n  -g, --global           Check globally installed packages\n  -h, --help             Print help\n\nPackage Manager Behavior:\n  pnpm:    Shows current, wanted, and latest versions in table format\n  npm:     Shows current, wanted, latest, location, and depended by\n  yarn@1:  Shows package info with current, wanted, latest, and URL\n  yarn@2+: Uses interactive 'upgrade-interactive' command\n\nExit Codes:\n  0: No outdated packages found\n  1: Outdated packages found (not an error)\n  Other: Command failed\n\nExamples:\n  vite outdated                        # Check all packages\n  vite outdated react                  # Check specific package\n  vite outdated \"*babel*\" \"eslint-*\"   # Check with patterns (pnpm)\n  vite outdated --format json          # JSON output\n  vite outdated --long                 # Verbose output\n  vite outdated -r                     # Recursive across workspaces\n  vite outdated --filter app           # Check in specific workspace\n  vite outdated -w                     # Include workspace root (pnpm)\n  vite outdated -w -r                  # Include workspace root and recursive (pnpm)\n  vite outdated --prod                 # Only production deps (pnpm)\n  vite outdated --compatible           # Only compatible versions (pnpm)\n  vite outdated --sort-by name         # Sort results by name (pnpm)\n  vite outdated -g                     # Check global packages\n```\n\n## Performance Considerations\n\n1. **No Caching**: Queries remote registry, caching would be stale\n2. **Network Dependent**: Performance depends on registry response time\n3. **Parallel Checks**: Some package managers parallelize version checks\n4. **JSON Output**: Faster to parse programmatically than table format\n\n## Security Considerations\n\n1. **Read-Only**: Only queries package versions, no modifications\n2. **Registry Trust**: Relies on package registry for version information\n3. **Vulnerability Detection**: Helps identify packages with known vulnerabilities\n4. **Safe for CI**: Can be run safely in CI/CD pipelines\n5. **Audit Integration**: Results can inform security audits\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New command is additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Migration Path\n\n### Adoption\n\nUsers can start using immediately:\n\n```bash\n# Old way\npnpm outdated\nnpm outdated\nyarn outdated\n\n# New way (works with any package manager)\nvite outdated\n```\n\n### CI/CD Integration\n\n```yaml\n# Check for outdated packages\n- run: vite outdated --format json > outdated.json\n\n# Fail build if critical packages are outdated\n- run: |\n    vite outdated --format json > outdated.json\n    # Parse JSON and check for critical packages\n    node scripts/check-critical-outdated.js\n\n# Weekly outdated report\n- run: vite outdated -r --format json > weekly-outdated-report.json\n```\n\n## Real-World Usage Examples\n\n### Checking for Updates\n\n```bash\n# Check all packages\nvite outdated\n\n# Check specific packages\nvite outdated react react-dom\n\n# Check with pattern (pnpm)\nvite outdated \"@babel/*\" \"eslint-*\"\n```\n\n### Production Dependency Updates\n\n```bash\n# Only production dependencies (pnpm)\nvite outdated --prod\n\n# Check with JSON output for automation\nvite outdated --prod --format json > prod-outdated.json\n```\n\n### Workspace Analysis\n\n```bash\n# Check all workspaces\nvite outdated -r\n\n# Check specific workspace\nvite outdated --filter app\n\n# Compare workspaces\nvite outdated --filter \"app*\" -r\n```\n\n### Compatible Version Updates\n\n```bash\n# Only show versions that satisfy package.json (pnpm)\nvite outdated --compatible\n\n# Show all possible updates\nvite outdated\n```\n\n### Global Package Updates\n\n```bash\n# Check globally installed packages\nvite outdated -g\n\n# Check specific global package\nvite outdated -g typescript\n```\n\n## Package Manager Compatibility\n\n| Feature             | pnpm               | npm                           | yarn@1           | yarn@2+             | Notes                    |\n| ------------------- | ------------------ | ----------------------------- | ---------------- | ------------------- | ------------------------ |\n| Basic command       | ✅ `outdated`      | ✅ `outdated`                 | ✅ `outdated`    | ⚠️ `upgrade-int...` | yarn@2+ uses interactive |\n| Pattern matching    | ✅ Glob patterns   | ⚠️ Package names              | ⚠️ Package names | ❌ Not supported    | pnpm supports globs      |\n| JSON output         | ✅ `--format json` | ✅ `--json`                   | ❌ Not supported | ❌ Not supported    | Different flags          |\n| Long output         | ✅ `--long`        | ✅ `--long`                   | ❌ Not supported | ❌ Not supported    | pnpm and npm only        |\n| Parseable           | ❌ Not supported   | ✅ `--parseable`              | ❌ Not supported | ❌ Not supported    | npm only                 |\n| Recursive           | ✅ `-r`            | ❌ Not supported              | ❌ Not supported | ❌ Not supported    | pnpm only                |\n| Workspace filter    | ✅ `--filter`      | ✅ `--workspace`              | ❌ Not supported | ❌ Not supported    | Different flags          |\n| Workspace root      | ✅ `-w`            | ✅ `--include-workspace-root` | ❌ Not supported | ❌ Not supported    | Different flags          |\n| Dep type filter     | ✅ `--prod/--dev`  | ❌ Not supported              | ❌ Not supported | ❌ Not supported    | pnpm only                |\n| Compatible only     | ✅ `--compatible`  | ❌ Not supported              | ❌ Not supported | ❌ Not supported    | pnpm only                |\n| Sort results        | ✅ `--sort-by`     | ❌ Not supported              | ❌ Not supported | ❌ Not supported    | pnpm only                |\n| Global check        | ✅ `-g`            | ✅ `-g`                       | ❌ Not supported | ❌ Not supported    | pnpm and npm             |\n| Show all transitive | ⚠️ Use `-r`        | ✅ `--all`                    | ❌ Not supported | ❌ Not supported    | Different approaches     |\n\n## Future Enhancements\n\n### 1. Severity Indicators\n\nShow update severity based on semver:\n\n```bash\nvite outdated --with-severity\n\nPackage         Current  Wanted  Latest  Severity\nreact           18.2.0   18.3.1  18.3.1  Minor\nlodash          4.17.20  4.17.21 4.17.21 Patch\nwebpack         5.0.0    5.0.0   6.0.0   Major ⚠️\n```\n\n### 2. Security Integration\n\nIntegrate with security advisories:\n\n```bash\nvite outdated --format json --with-security\n\nPackage         Current  Latest  Security\nlodash          4.17.20  4.17.21 🔴 High severity vulnerability\naxios           0.21.0   1.7.0   🟡 Moderate severity issue\nreact           18.2.0   18.3.1  ✅ No known issues\n```\n\n### 3. Update Plan Generation\n\nGenerate update plan with dependency analysis:\n\n```bash\nvite outdated --format json --plan > update-plan.json\n\n# Output:\n{\n  \"safeUpdates\": [\"lodash@4.17.21\", \"react@18.3.1\"],\n  \"breakingUpdates\": [\"webpack@6.0.0\"],\n  \"blockedBy\": {\n    \"webpack\": [\"babel-loader requires webpack@5\"]\n  }\n}\n```\n\n### 4. Interactive Mode\n\nAdd interactive selection mode for all package managers:\n\n```bash\nvite outdated --interactive\n\n# Shows interactive UI:\n┌─ Outdated Packages ────────────────────┐\n│ [x] react       18.2.0 → 18.3.1       │\n│ [x] lodash      4.17.20 → 4.17.21     │\n│ [ ] webpack     5.0.0 → 6.0.0 (major) │\n└────────────────────────────────────────┘\nPress <space> to select, <enter> to update\n```\n\n### 5. Change Log Integration\n\nShow change logs for updates:\n\n```bash\nvite outdated --with-changelog\n\nPackage: react 18.2.0 → 18.3.1\nChanges:\n- Fix: Memory leak in useEffect\n- Feat: New useDeferredValue hook\n- Perf: Improved rendering performance\n```\n\n## Open Questions\n\n1. **Should we handle exit code 1 differently?**\n   - Proposed: No, treat as success when outdated packages found\n   - Matches package manager behavior\n   - Expected by users\n\n2. **Should we add a --fix flag to auto-update?**\n   - Proposed: No, use separate `vp update` command\n   - Keep commands focused\n   - Prevents accidental updates\n\n3. **Should we support custom output formats?**\n   - Proposed: No, use native package manager output\n   - Simpler implementation\n   - Familiar to users\n   - Can add in future if needed\n\n4. **Should we cache registry queries?**\n   - Proposed: No, always query fresh data\n   - Registry data changes frequently\n   - Users expect current information\n\n5. **Should we support yarn@2+ differently?**\n   - Proposed: Yes, use `upgrade-interactive`\n   - Matches yarn@2+ recommendations\n   - Provide note to users about different UI\n\n## Success Metrics\n\n1. **Adoption**: % of users using `vite outdated` vs direct package manager\n2. **Update Frequency**: How often users update packages after checking\n3. **CI Integration**: Usage in CI/CD for outdated checks\n4. **User Feedback**: Survey/issues about command usefulness\n5. **Security Impact**: Reduction in outdated packages with vulnerabilities\n\n## Conclusion\n\nThis RFC proposes adding `vite outdated` command to provide a unified interface for checking outdated packages across pnpm/npm/yarn. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports pattern matching (pnpm) with graceful degradation\n- ✅ Full pnpm feature support (format, filters, compatible, sorting)\n- ✅ npm and yarn compatibility with appropriate warnings\n- ✅ Workspace-aware operations\n- ✅ Multiple output formats (json, table, list, parseable)\n- ✅ Proper exit code handling (1 = outdated found)\n- ✅ No caching (always fresh data)\n- ✅ Security-conscious (helps identify vulnerable packages)\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ Extensible for future enhancements (severity, security, interactive)\n\nThe implementation follows the same patterns as other package management commands while providing the dependency update checking features developers need to maintain current, secure dependencies across their projects.\n"
  },
  {
    "path": "rfcs/pack-command.md",
    "content": "# RFC: `vp pack` Command\n\n## Summary\n\n`vp pack` bundles TypeScript/JavaScript libraries using tsdown (Rolldown-powered bundler). Configured via `vite.config.ts` under the `pack` key.\n\n## Motivation\n\nUnified library bundling integrated into the Vite+ toolchain, replacing standalone `tsdown` CLI usage. A single config file (`vite.config.ts`) manages all tools — dev server, build, test, lint, and now pack.\n\n### Current Pain Points\n\n```bash\n# Standalone tsdown requires its own config file\nnpx tsdown src/index.ts --format esm --dts\n\n# Separate config from the rest of the Vite ecosystem\n# tsdown.config.ts vs vite.config.ts — fragmented tooling\n```\n\n### Proposed Solution\n\n```bash\n# Integrated into vp CLI\nvp pack src/index.ts --format esm --dts\n\n# Config lives in vite.config.ts alongside everything else\n# vite.config.ts\nexport default {\n  pack: { entry: 'src/index.ts', format: ['esm', 'cjs'], dts: true }\n}\n```\n\n## Command Syntax\n\n```bash\nvp pack [...files] [options]\n```\n\n### Usage Examples\n\n```bash\n# Bundle with defaults (ESM, node platform)\nvp pack src/index.ts\n\n# Multiple formats\nvp pack src/index.ts --format esm --format cjs\n\n# With declaration files\nvp pack src/index.ts --dts\n\n# Watch mode with success hook\nvp pack src/index.ts --watch --on-success 'node dist/index.mjs'\n\n# Workspace mode\nvp pack --workspace --filter my-lib\n\n# Bundle as executable (experimental, Node.js >= 25.7.0)\nvp pack src/cli.ts --exe\n```\n\n## CLI Options\n\n### Input\n\n- `[...files]` — Entry files to bundle\n- `--config-loader <loader>` — Config loader to use: `auto`, `native`, `unrun` (default: `auto`)\n- `--no-config` — Disable config file\n- `--from-vite [vitest]` — Reuse config from Vite or Vitest\n\n### Output\n\n- `-f, --format <format>` — Bundle format: `esm`, `cjs`, `iife`, `umd` (default: `esm`)\n- `-d, --out-dir <dir>` — Output directory (default: `dist`)\n- `--clean` — Clean output directory, `--no-clean` to disable\n- `--sourcemap` — Generate source map (default: `false`)\n- `--shims` — Enable CJS and ESM shims (default: `false`)\n- `--minify` — Minify output\n\n### Declaration Files\n\n- `--dts` — Generate `.d.ts` files\n\n### Platform & Target\n\n- `--platform <platform>` — Target platform: `node`, `browser`, `neutral` (default: `node`)\n- `--target <target>` — Bundle target, e.g., `es2015`, `esnext`\n\n### Dependencies\n\n- `--deps.never-bundle <module>` — Mark dependencies as external\n- `--treeshake` — Tree-shake bundle (default: `true`)\n\n### Quality Checks\n\n- `--publint` — Enable publint (default: `false`)\n- `--attw` — Enable Are the types wrong integration (default: `false`)\n- `--unused` — Enable unused dependencies check (default: `false`)\n\n### Watch Mode\n\n- `-w, --watch [path]` — Watch mode\n- `--ignore-watch <path>` — Ignore custom paths in watch mode\n- `--on-success <command>` — Command to run on success\n\n### Environment\n\n- `--env.* <value>` — Define compile-time env variables\n- `--env-file <file>` — Load environment variables from a file (variables in `--env` take precedence)\n- `--env-prefix <prefix>` — Prefix for env variables to inject into the bundle (default: `VITE_PACK_,TSDOWN_`)\n\n### Workspace\n\n- `-W, --workspace [dir]` — Enable workspace mode\n- `-F, --filter <pattern>` — Filter configs (cwd or name), e.g., `/pkg-name$/` or `pkg-name`\n\n### Other\n\n- `--copy <dir>` — Copy files to output dir\n- `--public-dir <dir>` — Alias for `--copy` (deprecated)\n- `--tsconfig <tsconfig>` — Set tsconfig path\n- `--unbundle` — Unbundle mode\n- `--report` — Size report (default: `true`)\n- `--exports` — Generate export-related metadata for package.json (experimental)\n- `--debug [feat]` — Show debug logs\n- `-l, --logLevel <level>` — Set log level: `info`, `warn`, `error`, `silent`\n- `--fail-on-warn` — Fail on warnings (default: `true`)\n- `--no-write` — Disable writing files to disk, incompatible with watch mode\n- `--devtools` — Enable devtools integration\n\n### Executable (Experimental)\n\n- `--exe` — Bundle as Node.js Single Executable Application (SEA)\n  - Requires Node.js >= 25.7.0\n  - Single entry point only\n  - Defaults to ESM format, DTS generation disabled by default\n  - On macOS, applies ad-hoc codesigning automatically\n\n## Configuration\n\nConfig is specified in `vite.config.ts` under the `pack` key:\n\n```ts\n// Single config\nexport default {\n  pack: {\n    entry: 'src/index.ts',\n    format: ['esm', 'cjs'],\n    dts: true,\n  },\n};\n\n// Array for multiple configs\nexport default {\n  pack: [\n    { entry: 'src/index.ts', format: ['esm'], dts: true },\n    { entry: 'src/cli.ts', format: ['cjs'] },\n  ],\n};\n```\n\nCLI flags override config file values. When both are provided, CLI flags take precedence.\n\n## Architecture\n\n### Command Dispatch\n\n```\nGlobal CLI (Rust) ─── Category C delegation ───▸ Local CLI (pack-bin.ts) ───▸ tsdown\n```\n\n1. **Global CLI** (`crates/vite_global_cli/src/cli.rs`): The `Pack` command variant uses `trailing_var_arg` to capture all arguments, then unconditionally delegates to the local CLI.\n2. **Local CLI** (`packages/cli/src/pack-bin.ts`): Parses CLI options with `cac`, resolves config from `vite.config.ts`, and calls tsdown's `resolveUserConfig` + `buildWithConfigs`.\n3. **tsdown**: Handles all bundling logic, including the new SEA/exe feature.\n\n### Config Resolution\n\n```\nvite.config.ts (pack key) ──▸ merge with CLI flags ──▸ resolveUserConfig() ──▸ buildWithConfigs()\n```\n\nThe local CLI:\n\n1. Resolves Vite config via `resolveConfig()` to find `vite.config.ts`\n2. Reads the `pack` key (object or array)\n3. Merges each pack config with CLI flags (CLI wins)\n4. Passes through to tsdown's `resolveUserConfig` for full resolution\n5. Calls `buildWithConfigs` with all resolved configs\n\n### Environment Variable Prefix\n\n- Default prefix: `VITE_PACK_` (primary) and `TSDOWN_` (migration compatibility)\n- Variables matching these prefixes are injected into the bundle at compile time\n- Customizable via `--env-prefix`\n\n### tsdown Integration\n\ntsdown is bundled inside `@voidzero-dev/vite-plus-core/pack`:\n\n- `packages/core/build.ts` bundles tsdown's JS, CJS dependencies, and types\n- `packages/core/package.json` tracks `bundledVersions.tsdown`\n- Re-exported via `packages/cli/src/pack.ts`\n\n## `--exe` Feature (Experimental)\n\nThe `--exe` flag bundles the output as a Node.js Single Executable Application (SEA).\n\n### Requirements\n\n- Node.js >= 25.7.0 (uses the `node --build-sea` API)\n- Single entry point only\n\n### Behavior\n\nWhen `--exe` is passed:\n\n1. tsdown defaults to ESM format\n2. DTS generation is disabled by default\n3. The bundle is embedded into a Node.js SEA blob\n4. On macOS, ad-hoc codesigning is applied automatically\n5. The resulting executable is a standalone binary\n\n### Error Handling\n\nIf Node.js version is too old:\n\n```\nNode.js version v22.22.0 does not support `exe` option. Please upgrade to Node.js 25.7.0 or later.\n```\n\n## Relationship with `vp pm pack`\n\nThese are distinct commands:\n\n| Command      | Purpose                       | Output              |\n| ------------ | ----------------------------- | ------------------- |\n| `vp pack`    | Library bundling via tsdown   | `dist/` directory   |\n| `vp pm pack` | Tarball creation via npm/pnpm | `.tgz` package file |\n\n## Design Decisions\n\n### 1. Config in `vite.config.ts` (Not `tsdown.config.ts`)\n\n**Decision**: Pack config lives under the `pack` key in `vite.config.ts`.\n\n**Rationale**:\n\n- Single config file for the entire Vite+ toolchain\n- Consistent with how `vp build`, `vp test`, etc. are configured\n- Reduces config file proliferation in projects\n\n### 2. `VITE_PACK_` Env Prefix (+ `TSDOWN_` for Migration)\n\n**Decision**: Default env prefix is `VITE_PACK_` with `TSDOWN_` as a migration-compatible fallback.\n\n**Rationale**:\n\n- `VITE_PACK_` follows Vite+ naming conventions\n- `TSDOWN_` ensures projects migrating from standalone tsdown continue to work\n- Users can override with `--env-prefix`\n\n### 3. tsdown Bundled Inside Core\n\n**Decision**: tsdown is bundled inside `@voidzero-dev/vite-plus-core/pack` rather than used as a direct dependency.\n\n**Rationale**:\n\n- Ensures consistent tsdown version across all vite-plus users\n- Avoids version conflicts in monorepos\n- The core build process bundles JS, CJS deps, and types together\n\n### 4. Category C Delegation\n\n**Decision**: The global CLI unconditionally delegates to the local CLI for `pack`.\n\n**Rationale**:\n\n- Pack requires project context (config file, dependencies, etc.)\n- Follows the same pattern as `build`, `test`, `lint`\n- No meaningful global-only behavior for bundling\n\n## CLI Help Output\n\n```bash\n$ vp pack -h\nvp pack\n\nUsage:\n  $ vp pack [...files]\n\nCommands:\n  [...files]  Bundle files\n\nOptions:\n  --config-loader <loader>  Config loader to use: auto, native, unrun (default: auto)\n  --no-config               Disable config file\n  -f, --format <format>     Bundle format: esm, cjs, iife, umd (default: esm)\n  --clean                   Clean output directory, --no-clean to disable\n  --deps.never-bundle <module>  Mark dependencies as external\n  --minify                  Minify output\n  --devtools                Enable devtools integration\n  --debug [feat]            Show debug logs\n  --target <target>         Bundle target, e.g \"es2015\", \"esnext\"\n  -l, --logLevel <level>    Set log level: info, warn, error, silent\n  --fail-on-warn            Fail on warnings (default: true)\n  --no-write                Disable writing files to disk, incompatible with watch mode\n  -d, --out-dir <dir>       Output directory (default: dist)\n  --treeshake               Tree-shake bundle (default: true)\n  --sourcemap               Generate source map (default: false)\n  --shims                   Enable cjs and esm shims (default: false)\n  --platform <platform>     Target platform (default: node)\n  --dts                     Generate dts files\n  --publint                 Enable publint (default: false)\n  --attw                    Enable Are the types wrong integration (default: false)\n  --unused                  Enable unused dependencies check (default: false)\n  -w, --watch [path]        Watch mode\n  --ignore-watch <path>     Ignore custom paths in watch mode\n  --from-vite [vitest]      Reuse config from Vite or Vitest\n  --report                  Size report (default: true)\n  --env.* <value>           Define compile-time env variables\n  --env-file <file>         Load environment variables from a file\n  --env-prefix <prefix>     Prefix for env variables to inject into the bundle\n  --on-success <command>    Command to run on success\n  --copy <dir>              Copy files to output dir\n  --public-dir <dir>        Alias for --copy, deprecated\n  --tsconfig <tsconfig>     Set tsconfig path\n  --unbundle                Unbundle mode\n  -W, --workspace [dir]     Enable workspace mode\n  -F, --filter <pattern>    Filter configs (cwd or name)\n  --exports                 Generate export-related metadata for package.json (experimental)\n  --exe                     Bundle as executable using Node.js SEA (experimental)\n  -h, --help                Display this message\n```\n\n## Snap Tests\n\n### Local CLI Test: `command-pack`\n\n**Location**: `packages/cli/snap-tests/command-pack/`\n\nTests `vp pack -h` (help output includes all options including `--exe`) and `vp run pack` (build and cache hit).\n\n### Local CLI Test: `command-pack-exe`\n\n**Location**: `packages/cli/snap-tests/command-pack-exe/`\n\nTests `vp pack src/index.ts --exe` error behavior when Node.js version is below 25.7.0.\n\n## Backward Compatibility\n\nThis RFC documents an existing command with no breaking changes:\n\n- All existing `vp pack` options continue to work\n- The new `--exe` flag is purely additive\n- Config format in `vite.config.ts` is unchanged\n\n## Exe Advanced Configuration\n\n### Programmatic `ExeOptions`\n\nThe `exe` option accepts an object for advanced configuration:\n\n```ts\nexport default {\n  pack: {\n    entry: 'src/cli.ts',\n    exe: {\n      seaConfig: {\n        /* Node.js SEA config overrides */\n      },\n      fileName: 'my-cli',\n      targets: [\n        { platform: 'linux', arch: 'x64', nodeVersion: '25.7.0' },\n        { platform: 'darwin', arch: 'arm64' },\n      ],\n    },\n  },\n};\n```\n\n### Cross-Platform Executable Building\n\nCross-platform builds are supported via the `@tsdown/exe` package (optional peer dependency). The `targets` option accepts an array of `{ platform, arch, nodeVersion }` objects to build executables for different platforms from a single host.\n\n## Conclusion\n\n`vp pack` integrates tsdown-powered library bundling into the Vite+ toolchain. By using `vite.config.ts` for configuration and following the Category C delegation pattern, it provides a consistent developer experience alongside `vp build`, `vp test`, and `vp lint`. The new `--exe` flag (experimental) enables bundling as standalone Node.js executables via the SEA API.\n"
  },
  {
    "path": "rfcs/pm-command-group.md",
    "content": "# RFC: Vite+ Package Manager Utilities Command Group\n\n## Summary\n\nAdd `vp pm` command group that provides a set of utilities for working with package managers. The `pm` command group offers direct access to package manager utilities like cache management, package publishing, configuration, and more. These are pass-through commands that delegate to the detected package manager (pnpm/npm/yarn) with minimal processing, providing a unified interface across different package managers.\n\n## Motivation\n\nCurrently, developers must use package manager-specific commands for various utilities:\n\n```bash\n# Cache management\npnpm store path\nnpm cache dir\nyarn cache dir\n\n# Package publishing\npnpm publish\nnpm publish\nyarn publish\n\n# Package information\npnpm list\nnpm list\nyarn list\n\n# Configuration\npnpm config get\nnpm config get\nyarn config get\n```\n\nThis creates several issues:\n\n1. **Cognitive Load**: Developers must remember different commands and flags for each package manager\n2. **Context Switching**: When working across projects with different package managers, developers need to switch mental models\n3. **Script Portability**: Scripts that use package manager utilities are tied to a specific package manager\n4. **Missing Abstraction**: While Vite+ provides abstractions for install/add/remove/update, it lacks utilities for cache, publish, config, etc.\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm store path                       # pnpm project\nnpm cache dir                         # npm project\nyarn cache dir                        # yarn project\n\n# Different command names\npnpm list --depth 0                   # pnpm - list packages\nnpm list --depth 0                    # npm - list packages\nyarn list --depth 0                   # yarn - list packages\n\n# Different config commands\npnpm config get registry              # pnpm\nnpm config get registry               # npm\nyarn config get registry              # yarn\n\n# Different cache cleaning\npnpm store prune                      # pnpm\nnpm cache clean --force               # npm\nyarn cache clean                      # yarn\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp pm cache                         # Show cache directory\nvp pm cache clean                   # Clean cache\nvp pm list                          # List installed packages\nvp pm config get registry           # Get config value\nvp pm publish                       # Publish package\nvp pm pack                          # Create package tarball\nvp pm prune                         # Remove unnecessary packages\nvp pm owner list <pkg>              # List package owners\nvp pm view <pkg>                    # View package information\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n```bash\nvp pm <subcommand> [OPTIONS] [ARGS...]\n```\n\n**Subcommands:**\n\n1. **prune**: Remove unnecessary packages\n2. **pack**: Create a tarball of the package\n3. **list** (alias: **ls**): List installed packages\n4. **view**: View package information from the registry\n5. **publish**: Publish package to registry\n6. **owner**: Manage package owners\n7. **cache**: Manage package cache\n8. **config**: Manage package manager configuration\n9. **login**: Log in to the registry\n10. **logout**: Log out from the registry\n11. **whoami**: Show the currently logged-in user\n12. **token**: Manage registry authentication tokens\n13. **audit**: Run a security audit on installed packages\n14. **dist-tag**: Manage distribution tags on packages\n15. **deprecate**: Deprecate a version of a package\n16. **search**: Search the registry for packages\n17. **rebuild**: Rebuild native addons\n18. **fund**: Show funding information for installed packages\n19. **ping**: Ping the registry\n\n### Subcommand Details\n\n#### 1. vp pm prune\n\nRemove unnecessary packages from node_modules.\n\n```bash\nvp pm prune [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Remove all extraneous packages\nvp pm prune\n\n# Remove devDependencies (production only)\nvp pm prune --prod\n\n# Remove optional dependencies\nvp pm prune --no-optional\n```\n\n**Options:**\n\n- `--prod`: Remove devDependencies\n- `--no-optional`: Remove optional dependencies\n\n#### 2. vp pm pack\n\nCreate a tarball archive of the package.\n\n```bash\nvp pm pack [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Create tarball in current directory\nvp pm pack\n\n# Specify output file path\nvp pm pack --out ./dist/package.tgz\n\n# Use placeholders for package name and version (pnpm/yarn@2+ only)\nvp pm pack --out ./dist/%s-%v.tgz\n\n# Specify output directory\nvp pm pack --pack-destination ./dist\n\n# Custom gzip compression level\nvp pm pack --pack-gzip-level 9\n\n# Pack all workspace packages\nvp pm pack -r\n\n# Pack specific workspace packages\nvp pm pack --filter app --filter web\n```\n\n**Options:**\n\n- `-r, --recursive`: Pack all workspace packages\n- `--filter <pattern>`: Filter packages to pack (can be used multiple times)\n- `--out <path>`: Customizes the output path for the tarball. Use `%s` and `%v` to include the package name and version (pnpm and yarn@2+ only), e.g., `%s.tgz` or `some-dir/%s-%v.tgz`. By default, the tarball is saved in the current working directory with the name `<package-name>-<version>.tgz`\n- `--pack-destination <dir>`: Directory where the tarball will be saved (pnpm and npm only)\n- `--pack-gzip-level <level>`: Gzip compression level 0-9 (pnpm only)\n- `--json`: Output in JSON format\n\n#### 3. vp pm list / vp pm ls\n\nList installed packages.\n\n```bash\nvp pm list [PATTERN] [OPTIONS]\nvp pm ls [PATTERN] [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# List all direct dependencies\nvp pm list\n\n# List dependencies matching pattern\nvp pm list react\n\n# Show dependency tree\nvp pm list --depth 2\n\n# JSON output\nvp pm list --json\n\n# List in specific workspace\nvp pm list --filter app\n\n# List across all workspaces\nvp pm list -r\n\n# List only production dependencies\nvp pm list --prod\n\n# List globally installed packages\nvp pm list -g\n```\n\n**Options:**\n\n- `--depth <n>`: Maximum depth of dependency tree\n- `--json`: JSON output format\n- `--long`: Extended information\n- `--parseable`: Parseable output\n- `--prod`: Only production dependencies\n- `--dev`: Only dev dependencies\n- `-r, --recursive`: List across all workspaces\n- `--filter <pattern>`: Filter by workspace (can be used multiple times)\n- `-g, --global`: List global packages\n\n#### 4. vp pm view / vp pm info / vp pm show\n\nView package information from the registry.\n\n```bash\nvp pm view [<package-spec>] [<field>[.subfield]...] [OPTIONS]\nvp pm info [<package-spec>] [<field>[.subfield]...] [OPTIONS]\nvp pm show [<package-spec>] [<field>[.subfield]...] [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# View package information\nvp pm view react\n\n# View specific version\nvp pm view react@18.3.1\n\n# View specific field\nvp pm view react version\nvp pm view react dist.tarball\n\n# View nested field\nvp pm view react dependencies.prop-types\n\n# JSON output\nvp pm view react --json\n\n# Use aliases\nvp pm info lodash\nvp pm show express\n```\n\n**Options:**\n\n- `--json`: JSON output format\n\n#### 5. vp pm publish\n\nPublish package to the registry.\n\n```bash\nvp pm publish [TARBALL|FOLDER] [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Publish current package\nvp pm publish\n\n# Publish specific tarball\nvp pm publish package.tgz\n\n# Dry run\nvp pm publish --dry-run\n\n# Set tag\nvp pm publish --tag beta\n\n# Set access level\nvp pm publish --access public\n\n# Recursive publish in monorepo\nvp pm publish -r\n\n# Publish with filter\nvp pm publish --filter app\n```\n\n**Options:**\n\n- `--dry-run`: Preview without actually publishing\n- `--tag <tag>`: Publish with specific tag (default: latest)\n- `--access <public|restricted>`: Access level\n- `--no-git-checks`: Skip git checks\n- `--force`: Force publish even if already exists\n- `-r, --recursive`: Publish all workspace packages\n- `--filter <pattern>`: Filter workspaces (pnpm)\n- `--workspace <name>`: Specific workspace (npm)\n\n#### 6. vp pm owner\n\nManage package owners.\n\n```bash\nvp pm owner <subcommand> <package>\n```\n\n**Subcommands:**\n\n- `list <package>`: List package owners\n- `add <user> <package>`: Add owner\n- `rm <user> <package>`: Remove owner\n\n**Examples:**\n\n```bash\n# List package owners\nvp pm owner list my-package\n\n# Add owner\nvp pm owner add username my-package\n\n# Remove owner\nvp pm owner rm username my-package\n```\n\n#### 7. vp pm cache\n\nManage package cache.\n\n```bash\nvp pm cache [SUBCOMMAND] [OPTIONS]\n```\n\n**Subcommands:**\n\n- `dir` / `path`: Show cache directory\n- `clean` / `clear`: Clean cache\n- `verify`: Verify cache integrity (npm)\n- `list`: List cached packages (pnpm)\n\n**Examples:**\n\n```bash\n# Show cache directory\nvp pm cache dir\nvp pm cache path\n\n# Clean cache\nvp pm cache clean\nvp pm cache clear\n\n# Force clean (npm)\nvp pm cache clean --force\n\n# Verify cache (npm)\nvp pm cache verify\n\n# List cached packages (pnpm)\nvp pm cache list\n```\n\n**Options:**\n\n- `--force`: Force cache clean (npm)\n\n#### 8. vp pm config / vp pm c\n\nManage package manager configuration.\n\n```bash\nvp pm config <subcommand> [key] [value] [OPTIONS]\nvp pm c <subcommand> [key] [value] [OPTIONS]\n```\n\n**Subcommands:**\n\n- `list`: List all configuration\n- `get <key>`: Get configuration value\n- `set <key> <value>`: Set configuration value\n- `delete <key>`: Delete configuration key\n\n**Examples:**\n\n```bash\n# List all config\nvp pm config list\n\n# Get config value\nvp pm config get registry\n\n# Set config value\nvp pm config set registry https://registry.npmjs.org\n\n# Set with JSON format (pnpm/yarn@2+)\nvp pm config set registry https://registry.npmjs.org --json\n\n# Set global config\nvp pm config set registry https://registry.npmjs.org --global\n\n# Set global config with location parameter (alternative)\nvp pm config set registry https://registry.npmjs.org --location global\n\n# Delete config key\nvp pm config delete registry\n\n# Use alias\nvp pm c get registry\n```\n\n**Options:**\n\n- `--json`: JSON output format (pnpm/yarn@2+)\n- `-g, --global`: Use global config (shorthand for `--location global`)\n- `--location <location>`: Config location: project (default) or global\n\n#### 9. vp pm login\n\nLog in to the registry to authenticate for publishing and other protected operations.\n\n```bash\nvp pm login [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Log in to the default registry\nvp pm login\n\n# Log in to a custom registry\nvp pm login --registry https://custom-registry.com\n\n# Log in with scope\nvp pm login --scope @myorg\n```\n\n**Options:**\n\n- `--registry <url>`: Registry URL to log in to\n- `--scope <scope>`: Associate the login with a scope\n\n#### 10. vp pm logout\n\nLog out from the registry, removing stored credentials.\n\n```bash\nvp pm logout [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Log out from the default registry\nvp pm logout\n\n# Log out from a custom registry\nvp pm logout --registry https://custom-registry.com\n\n# Log out with scope\nvp pm logout --scope @myorg\n```\n\n**Options:**\n\n- `--registry <url>`: Registry URL to log out from\n- `--scope <scope>`: Log out from a scoped registry\n\n#### 11. vp pm whoami\n\nDisplay the username of the currently logged-in user.\n\n```bash\nvp pm whoami [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Show logged-in user\nvp pm whoami\n\n# Show logged-in user for a custom registry\nvp pm whoami --registry https://custom-registry.com\n```\n\n**Options:**\n\n- `--registry <url>`: Registry URL to check\n\n#### 12. vp pm token\n\nManage registry authentication tokens. This command always delegates to `npm token` regardless of the detected package manager.\n\n```bash\nvp pm token <subcommand> [OPTIONS]\n```\n\n**Subcommands:**\n\n- `list`: List all known tokens\n- `create`: Create a new authentication token\n- `revoke <token|id>`: Revoke a token\n\n**Examples:**\n\n```bash\n# List all tokens\nvp pm token list\n\n# Create a new read-only token\nvp pm token create --read-only\n\n# Create a CIDR-whitelisted token\nvp pm token create --cidr 192.168.1.0/24\n\n# Revoke a token\nvp pm token revoke a1b2c3d4\n```\n\n**Options:**\n\n- `--read-only`: Create a read-only token\n- `--cidr <cidr>`: CIDR range for token restriction\n- `--registry <url>`: Registry URL\n\n#### 13. vp pm audit\n\nRun a security audit on installed packages to identify known vulnerabilities.\n\n```bash\nvp pm audit [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Run security audit\nvp pm audit\n\n# JSON output\nvp pm audit --json\n\n# Audit only production dependencies\nvp pm audit --prod\n\n# Fix vulnerabilities automatically\nvp pm audit fix\n\n# Set minimum severity level\nvp pm audit --audit-level high\n```\n\n**Options:**\n\n- `--json`: JSON output format\n- `--prod`: Audit only production dependencies\n- `--audit-level <level>`: Minimum severity to report (low, moderate, high, critical)\n- `fix`: Attempt to automatically fix vulnerabilities\n\n#### 14. vp pm dist-tag\n\nManage distribution tags on packages, allowing you to label specific versions with meaningful names.\n\n```bash\nvp pm dist-tag <subcommand> <pkg> [OPTIONS]\n```\n\n**Subcommands:**\n\n- `list [<pkg>]`: List distribution tags for a package\n- `add <pkg>@<version> <tag>`: Add a tag to a specific version\n- `rm <pkg> <tag>`: Remove a tag from a package\n\n**Examples:**\n\n```bash\n# List distribution tags\nvp pm dist-tag list my-package\n\n# Tag a specific version as beta\nvp pm dist-tag add my-package@1.0.0 beta\n\n# Remove a tag\nvp pm dist-tag rm my-package beta\n```\n\n**Options:**\n\n- `--registry <url>`: Registry URL\n- `--otp <otp>`: One-time password for authentication\n\n#### 15. vp pm deprecate\n\nDeprecate a version or range of versions of a package. This command always delegates to `npm deprecate` regardless of the detected package manager.\n\n```bash\nvp pm deprecate <package-spec> <message>\n```\n\n**Examples:**\n\n```bash\n# Deprecate a specific version\nvp pm deprecate my-package@1.0.0 \"Use v2 instead\"\n\n# Deprecate a range of versions\nvp pm deprecate \"my-package@<2.0.0\" \"Upgrade to v2 for security fixes\"\n\n# Un-deprecate by passing empty message\nvp pm deprecate my-package@1.0.0 \"\"\n```\n\n**Options:**\n\n- `--registry <url>`: Registry URL\n- `--otp <otp>`: One-time password for authentication\n\n#### 16. vp pm search\n\nSearch the registry for packages matching a query. This command always delegates to `npm search` regardless of the detected package manager.\n\n```bash\nvp pm search [OPTIONS] <search-terms...>\n```\n\n**Examples:**\n\n```bash\n# Search for packages\nvp pm search vite plugin\n\n# JSON output\nvp pm search vite plugin --json\n\n# Long format with description\nvp pm search vite plugin --long\n\n# Search with registry\nvp pm search vite plugin --registry https://custom-registry.com\n```\n\n**Options:**\n\n- `--json`: JSON output format\n- `--long`: Show extended information\n- `--registry <url>`: Registry URL\n- `--searchlimit <number>`: Limit number of results\n\n#### 17. vp pm rebuild\n\nRebuild native addons (e.g., node-gyp compiled modules) in the current project.\n\n```bash\nvp pm rebuild [OPTIONS] [<packages...>]\n```\n\n**Examples:**\n\n```bash\n# Rebuild all native addons\nvp pm rebuild\n\n# Rebuild specific packages\nvp pm rebuild node-sass sharp\n```\n\n**Options:**\n\n- Packages to rebuild can be specified as positional arguments\n\n#### 18. vp pm fund\n\nShow funding information for installed packages. This command always delegates to `npm fund` regardless of the detected package manager.\n\n```bash\nvp pm fund [OPTIONS] [<package>]\n```\n\n**Examples:**\n\n```bash\n# Show funding info for all dependencies\nvp pm fund\n\n# Show funding info for a specific package\nvp pm fund lodash\n\n# JSON output\nvp pm fund --json\n\n# Limit depth of dependency tree\nvp pm fund --depth 1\n```\n\n**Options:**\n\n- `--json`: JSON output format\n- `--depth <n>`: Maximum depth of dependency tree\n\n#### 19. vp pm ping\n\nPing the configured or specified registry to verify connectivity. This command always delegates to `npm ping` regardless of the detected package manager.\n\n```bash\nvp pm ping [OPTIONS]\n```\n\n**Examples:**\n\n```bash\n# Ping the default registry\nvp pm ping\n\n# Ping a custom registry\nvp pm ping --registry https://custom-registry.com\n```\n\n**Options:**\n\n- `--registry <url>`: Registry URL to ping\n\n### Command Mapping\n\n#### Prune Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/prune\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-prune\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/prune\n- The prune command isn't necessary. yarn install will prune extraneous packages.\n\n| Vite+ Flag      | pnpm            | npm               | yarn | Description                 |\n| --------------- | --------------- | ----------------- | ---- | --------------------------- |\n| `vp pm prune`   | `pnpm prune`    | `npm prune`       | N/A  | Remove unnecessary packages |\n| `--prod`        | `--prod`        | `--omit=dev`      | N/A  | Remove devDependencies      |\n| `--no-optional` | `--no-optional` | `--omit=optional` | N/A  | Remove optional deps        |\n\n**Note:**\n\n- npm supports prune with `--omit=dev` (for prod) and `--omit=optional` (for no-optional)\n- yarn doesn't have a prune command (automatic during install)\n\n#### Pack Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/pack\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-pack\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/pack\n- https://yarnpkg.com/cli/pack\n- https://yarnpkg.com/cli/workspaces/foreach (for yarn@2+ recursive packing)\n\n| Vite+ Flag                  | pnpm                 | npm                  | yarn@1       | yarn@2+                                       | Description                       |\n| --------------------------- | -------------------- | -------------------- | ------------ | --------------------------------------------- | --------------------------------- |\n| `vp pm pack`                | `pnpm pack`          | `npm pack`           | `yarn pack`  | `yarn pack`                                   | Create package tarball            |\n| `-r, --recursive`           | `--recursive`        | `--workspaces`       | N/A          | `workspaces foreach --all pack`               | Pack all workspace packages       |\n| `--filter <pattern>`        | `--filter`           | `--workspace`        | N/A          | `workspaces foreach --include <pattern> pack` | Filter packages to pack           |\n| `--out <path>`              | `--out`              | N/A                  | `--filename` | `--out`                                       | Output file path (supports %s/%v) |\n| `--pack-destination <dir>`  | `--pack-destination` | `--pack-destination` | N/A          | N/A                                           | Output directory                  |\n| `--pack-gzip-level <level>` | `--pack-gzip-level`  | N/A                  | N/A          | N/A                                           | Gzip compression level (0-9)      |\n| `--json`                    | `--json`             | `--json`             | `--json`     | `--json`                                      | JSON output                       |\n\n**Note:**\n\n- `-r, --recursive`: Pack all workspace packages\n  - pnpm uses `--recursive`\n  - npm uses `--workspaces`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ uses `yarn workspaces foreach --all pack`\n- `--filter <pattern>`: Filter packages to pack (can be used multiple times)\n  - pnpm uses `--filter <pattern>`\n  - npm uses `--workspace <pattern>`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ always uses `yarn workspaces foreach --all --include <pattern> pack`\n- `--out <path>`: Specifies the full output file path\n  - pnpm and yarn@2+ support `%s` (package name) and `%v` (version) placeholders\n  - yarn@1 uses `--filename` (does not support placeholders)\n  - npm does not support this option\n- `--pack-destination <dir>`: Specifies the output directory (file name auto-generated)\n  - Supported by pnpm and npm\n  - yarn does not support this option (prints warning and ignores)\n- `--pack-gzip-level <level>`: Gzip compression level (0-9)\n  - Only supported by pnpm\n  - npm and yarn do not support this option (prints warning and ignores)\n\n#### List Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/list\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-ls\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/list\n\n| Vite+ Flag           | pnpm              | npm                             | yarn@1        | yarn@2+       | Description                                   |\n| -------------------- | ----------------- | ------------------------------- | ------------- | ------------- | --------------------------------------------- |\n| `vp pm list`         | `pnpm list`       | `npm list`                      | `yarn list`   | N/A           | List installed packages                       |\n| `--depth <n>`        | `--depth <n>`     | `--depth <n>`                   | `--depth <n>` | N/A           | Limit tree depth                              |\n| `--json`             | `--json`          | `--json`                        | `--json`      | N/A           | JSON output                                   |\n| `--long`             | `--long`          | `--long`                        | N/A           | N/A           | Extended info                                 |\n| `--parseable`        | `--parseable`     | `--parseable`                   | N/A           | N/A           | Parseable format                              |\n| `-P, --prod`         | `--prod`          | `--include prod --include peer` | N/A           | N/A           | Production deps only                          |\n| `-D, --dev`          | `--dev`           | `--include dev`                 | N/A           | N/A           | Dev deps only                                 |\n| `--no-optional`      | `--no-optional`   | `--omit optional`               | N/A           | N/A           | Exclude optional deps                         |\n| `--exclude-peers`    | `--exclude-peers` | `--omit peer`                   | N/A           | N/A           | Exclude peer deps                             |\n| `--only-projects`    | `--only-projects` | N/A                             | N/A           | N/A           | Show only project packages (pnpm)             |\n| `--find-by <name>`   | `--find-by`       | N/A                             | N/A           | N/A           | Use finder function from .pnpmfile.cjs (pnpm) |\n| `-r, --recursive`    | `--recursive`     | `--workspaces`                  | N/A           | N/A           | List across workspaces                        |\n| `--filter <pattern>` | `--filter`        | `--workspace`                   | N/A           | N/A           | Filter workspace                              |\n| `-g, --global`       | `npm list -g`     | `npm list -g`                   | `npm list -g` | `npm list -g` | List global packages                          |\n\n**Note:**\n\n- yarn@2+ does not support the `list` command (command is ignored)\n- `-r, --recursive`: List across all workspaces\n  - pnpm uses `--recursive`\n  - npm uses `--workspaces`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `--filter <pattern>`: Filter by workspace (can be used multiple times)\n  - pnpm uses `--filter <pattern>` (comes before `list` command)\n  - npm uses `--workspace <pattern>` (comes after `list` command)\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `-P, --prod`: Show only production dependencies (and peer dependencies)\n  - pnpm uses `--prod`\n  - npm uses `--include prod --include peer`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `-D, --dev`: Show only dev dependencies\n  - pnpm uses `--dev`\n  - npm uses `--include dev`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `--no-optional`: Exclude optional dependencies\n  - pnpm uses `--no-optional`\n  - npm uses `--omit optional`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `--exclude-peers`: Exclude peer dependencies\n  - pnpm uses `--exclude-peers`\n  - npm uses `--omit peer`\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `--only-projects`: Show only project packages (workspace packages only, no dependencies)\n  - Only supported by pnpm\n  - npm does not support (prints warning and ignores)\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `--find-by <finder_name>`: Use a finder function defined in .pnpmfile.cjs to match dependencies by properties other than name\n  - Only supported by pnpm (pnpm-specific feature)\n  - npm does not support (prints warning and ignores)\n  - yarn@1 does not support (prints warning and ignores)\n  - yarn@2+ does not support list command at all\n- `-g, --global`: List globally installed packages\n  - All package managers delegate to `npm list -g` (since global installs use npm)\n  - Uses npm regardless of the detected package manager\n\n#### View Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/view (delegates to npm view)\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-view\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/info (delegates to npm view)\n- https://yarnpkg.com/cli/npm/info (delegates to npm view)\n\n| Vite+ Flag   | pnpm       | npm        | yarn@1     | yarn@2+    | Description       |\n| ------------ | ---------- | ---------- | ---------- | ---------- | ----------------- |\n| `vp pm view` | `npm view` | `npm view` | `npm view` | `npm view` | View package info |\n| `--json`     | `--json`   | `--json`   | `--json`   | `--json`   | JSON output       |\n\n**Note:**\n\n- All package managers delegate to `npm view` for viewing package information\n- pnpm and yarn both use npm's view/info functionality internally\n- Aliases: `vp pm info` and `vp pm show` work the same as `vp pm view`\n\n#### Publish Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/publish\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-publish\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/publish (delegates to npm publish)\n- https://yarnpkg.com/cli/npm/publish (delegates to npm publish)\n\n| Vite+ Flag                  | pnpm               | npm                | yarn@1             | yarn@2+            | Description                 |\n| --------------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------------------------- |\n| `vp pm publish`             | `pnpm publish`     | `npm publish`      | `npm publish`      | `npm publish`      | Publish package             |\n| `--dry-run`                 | `--dry-run`        | `--dry-run`        | `--dry-run`        | `--dry-run`        | Preview without publishing  |\n| `--tag <tag>`               | `--tag <tag>`      | `--tag <tag>`      | `--tag <tag>`      | `--tag <tag>`      | Publish tag                 |\n| `--access <level>`          | `--access <level>` | `--access <level>` | `--access <level>` | `--access <level>` | Public/restricted           |\n| `--otp <otp>`               | `--otp`            | `--otp`            | `--otp`            | `--otp`            | One-time password           |\n| `--no-git-checks`           | `--no-git-checks`  | N/A                | N/A                | N/A                | Skip git checks (pnpm)      |\n| `--publish-branch <branch>` | `--publish-branch` | N/A                | N/A                | N/A                | Set publish branch (pnpm)   |\n| `--report-summary`          | `--report-summary` | N/A                | N/A                | N/A                | Save publish summary (pnpm) |\n| `--force`                   | `--force`          | `--force`          | `--force`          | `--force`          | Force publish               |\n| `--json`                    | `--json`           | N/A                | N/A                | N/A                | JSON output (pnpm)          |\n| `-r, --recursive`           | `--recursive`      | `--workspaces`     | N/A                | N/A                | Publish workspaces          |\n| `--filter <pattern>`        | `--filter`         | `--workspace`      | N/A                | N/A                | Filter workspace            |\n\n**Note:**\n\n- All yarn versions delegate to `npm publish` for publishing packages\n- yarn@1 and yarn@2+ both use npm's publish functionality internally\n- `-r, --recursive`: Publish all workspace packages\n  - pnpm uses `--recursive`\n  - npm uses `--workspaces`\n  - yarn does not support (delegates to npm which doesn't support this in single publish mode)\n- `--filter <pattern>`: Filter workspace packages to publish (can be used multiple times)\n  - pnpm uses `--filter <pattern>` (comes before `publish` command)\n  - npm uses `--workspace <pattern>` (comes after `publish` command)\n  - yarn does not support (delegates to npm, can use --workspace)\n- `--no-git-checks`: Skip git checks before publishing\n  - Only supported by pnpm (pnpm-specific feature)\n  - npm does not support (prints warning and ignores)\n  - yarn does not support (delegates to npm which doesn't support it)\n- `--publish-branch <branch>`: Set the branch name to publish from\n  - Only supported by pnpm (pnpm-specific feature)\n  - npm does not support (prints warning and ignores)\n  - yarn does not support (delegates to npm which doesn't support it)\n- `--report-summary`: Save publish summary to pnpm-publish-summary.json\n  - Only supported by pnpm (pnpm-specific feature)\n  - npm does not support (prints warning and ignores)\n  - yarn does not support (delegates to npm which doesn't support it)\n- `--json`: JSON output\n  - Only supported by pnpm (pnpm-specific feature)\n  - npm does not support (prints warning and ignores)\n  - yarn does not support (delegates to npm which doesn't support it)\n- pnpm-specific features: `--no-git-checks`, `--publish-branch`, `--report-summary`, `--json`\n\n#### Owner Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/owner (delegates to npm owner)\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-owner\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/owner (delegates to npm owner)\n- https://yarnpkg.com/cli/npm/owner (delegates to npm owner)\n\n| Vite+ Flag                | pnpm             | npm              | yarn@1           | yarn@2+          | Description         |\n| ------------------------- | ---------------- | ---------------- | ---------------- | ---------------- | ------------------- |\n| `vp pm owner list <pkg>`  | `npm owner list` | `npm owner list` | `npm owner list` | `npm owner list` | List package owners |\n| `vp pm owner add <u> <p>` | `npm owner add`  | `npm owner add`  | `npm owner add`  | `npm owner add`  | Add owner           |\n| `vp pm owner rm <u> <p>`  | `npm owner rm`   | `npm owner rm`   | `npm owner rm`   | `npm owner rm`   | Remove owner        |\n| `--otp <otp>`             | `--otp`          | `--otp`          | `--otp`          | `--otp`          | One-time password   |\n\n**Note:**\n\n- All package managers delegate to `npm owner` for managing package ownership\n- pnpm and yarn both use npm's owner functionality internally\n- Alias: `vp pm author` works the same as `vp pm owner`\n\n#### Cache Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/store\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-cache\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/cache\n- https://yarnpkg.com/cli/cache\n\n| Vite+ Flag          | pnpm               | npm                    | yarn@1             | yarn@2+                       | Description          |\n| ------------------- | ------------------ | ---------------------- | ------------------ | ----------------------------- | -------------------- |\n| `vp pm cache dir`   | `pnpm store path`  | `npm config get cache` | `yarn cache dir`   | `yarn config get cacheFolder` | Show cache directory |\n| `vp pm cache path`  | Alias for `dir`    | Alias for `dir`        | Alias for `dir`    | Alias for `dir`               | Alias for dir        |\n| `vp pm cache clean` | `pnpm store prune` | `npm cache clean`      | `yarn cache clean` | `yarn cache clean`            | Clean cache          |\n\n**Note:**\n\n- `cache dir` / `cache path`: Show cache directory location\n  - pnpm uses `pnpm store path`\n  - npm uses `npm config get cache` (not `npm cache dir` which doesn't exist in modern npm)\n  - yarn@1 uses `yarn cache dir`\n  - yarn@2+ uses `yarn config get cacheFolder`\n- Subcommand aliases: `path` is an alias for `dir`\n\n#### Config Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/config\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-config\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/config\n- https://yarnpkg.com/cli/config\n\n| Vite+ Flag                  | pnpm                 | npm                 | yarn@1               | yarn@2+                     | Description        |\n| --------------------------- | -------------------- | ------------------- | -------------------- | --------------------------- | ------------------ |\n| `vp pm config list`         | `pnpm config list`   | `npm config list`   | `yarn config list`   | `yarn config`               | List configuration |\n| `vp pm config get <key>`    | `pnpm config get`    | `npm config get`    | `yarn config get`    | `yarn config get`           | Get config value   |\n| `vp pm config set <k> <v>`  | `pnpm config set`    | `npm config set`    | `yarn config set`    | `yarn config set`           | Set config value   |\n| `vp pm config delete <key>` | `pnpm config delete` | `npm config delete` | `yarn config delete` | `yarn config unset`         | Delete config key  |\n| `--json`                    | `--json`             | `--json`            | `--json`             | `--json`                    | JSON output        |\n| `-g, --global`              | `--global`           | `--global`          | `--global`           | `--home`                    | Global config      |\n| `--location <location>`     | `--location`         | `--location`        | N/A                  | Maps to `--home` for global | Config location    |\n\n**Note:**\n\n- Alias: `vp pm c` works the same as `vp pm config`\n- `-g, --global`: Shorthand for setting global configuration\n  - pnpm uses `--global`\n  - npm uses `--global`\n  - yarn@1 uses `--global`\n  - yarn@2+ uses `--home`\n  - Equivalent to `--location global`\n- `--location`: Specify config location (default: global)\n  - pnpm supports: `project`, `global` (default)\n  - npm supports: `project`, `user`, `global` (default), etc.\n  - yarn@1 does not support (prints warning and ignores, uses global by default)\n  - yarn@2+ maps `global` to `--home` flag; `project` is default when no flag specified\n- `--json`: JSON output format\n  - Supported by all package managers for output formatting (list/get commands)\n  - For `set` command with JSON value: pnpm, npm, yarn@2+ support; yarn@1 does not support\n\n#### Login Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-login\n\n**pnpm references:**\n\n- https://pnpm.io/cli/login (delegates to npm login)\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/login\n- https://yarnpkg.com/cli/npm/login\n\n| Vite+ Flag         | pnpm         | npm          | yarn@1       | yarn@2+          | Description          |\n| ------------------ | ------------ | ------------ | ------------ | ---------------- | -------------------- |\n| `vp pm login`      | `npm login`  | `npm login`  | `yarn login` | `yarn npm login` | Log in to registry   |\n| `--registry <url>` | `--registry` | `--registry` | `--registry` | `--registry`     | Registry URL         |\n| `--scope <scope>`  | `--scope`    | `--scope`    | `--scope`    | `--scope`        | Associate with scope |\n\n**Note:**\n\n- pnpm delegates to `npm login` for authentication\n- yarn@1 uses its own `yarn login` command\n- yarn@2+ uses `yarn npm login` via the npm plugin\n\n#### Logout Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-logout\n\n**pnpm references:**\n\n- https://pnpm.io/cli/logout (delegates to npm logout)\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/logout\n- https://yarnpkg.com/cli/npm/logout\n\n| Vite+ Flag         | pnpm         | npm          | yarn@1        | yarn@2+           | Description           |\n| ------------------ | ------------ | ------------ | ------------- | ----------------- | --------------------- |\n| `vp pm logout`     | `npm logout` | `npm logout` | `yarn logout` | `yarn npm logout` | Log out from registry |\n| `--registry <url>` | `--registry` | `--registry` | `--registry`  | `--registry`      | Registry URL          |\n| `--scope <scope>`  | `--scope`    | `--scope`    | `--scope`     | `--scope`         | Scoped registry       |\n\n**Note:**\n\n- pnpm delegates to `npm logout` for authentication\n- yarn@1 uses its own `yarn logout` command\n- yarn@2+ uses `yarn npm logout` via the npm plugin\n\n#### Whoami Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-whoami\n\n**pnpm references:**\n\n- https://pnpm.io/cli/whoami (delegates to npm whoami)\n\n**yarn references:**\n\n- https://yarnpkg.com/cli/npm/whoami\n\n| Vite+ Flag         | pnpm         | npm          | yarn@1     | yarn@2+           | Description         |\n| ------------------ | ------------ | ------------ | ---------- | ----------------- | ------------------- |\n| `vp pm whoami`     | `npm whoami` | `npm whoami` | N/A (warn) | `yarn npm whoami` | Show logged-in user |\n| `--registry <url>` | `--registry` | `--registry` | N/A        | `--registry`      | Registry URL        |\n\n**Note:**\n\n- pnpm delegates to `npm whoami` for authentication\n- yarn@1 does not have a `whoami` command (prints warning and ignores)\n- yarn@2+ uses `yarn npm whoami` via the npm plugin\n\n#### Token Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-token\n\n| Vite+ Flag           | pnpm               | npm                | yarn@1     | yarn@2+    | Description      |\n| -------------------- | ------------------ | ------------------ | ---------- | ---------- | ---------------- |\n| `vp pm token list`   | `npm token list`   | `npm token list`   | N/A (warn) | N/A (warn) | List tokens      |\n| `vp pm token create` | `npm token create` | `npm token create` | N/A (warn) | N/A (warn) | Create token     |\n| `vp pm token revoke` | `npm token revoke` | `npm token revoke` | N/A (warn) | N/A (warn) | Revoke token     |\n| `--read-only`        | `--read-only`      | `--read-only`      | N/A        | N/A        | Read-only token  |\n| `--cidr <cidr>`      | `--cidr`           | `--cidr`           | N/A        | N/A        | CIDR restriction |\n\n**Note:**\n\n- All package managers delegate to `npm token` since token management is npm-specific\n- yarn@1 and yarn@2+ do not have a `token` command (prints warning and ignores)\n\n#### Audit Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/audit\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-audit\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/audit\n- https://yarnpkg.com/cli/npm/audit\n\n| Vite+ Flag              | pnpm            | npm             | yarn@1          | yarn@2+                    | Description        |\n| ----------------------- | --------------- | --------------- | --------------- | -------------------------- | ------------------ |\n| `vp pm audit`           | `pnpm audit`    | `npm audit`     | `yarn audit`    | `yarn npm audit`           | Run security audit |\n| `--json`                | `--json`        | `--json`        | `--json`        | `--json`                   | JSON output        |\n| `--prod`                | `--prod`        | `--omit=dev`    | `--groups prod` | `--environment production` | Production only    |\n| `--audit-level <level>` | `--audit-level` | `--audit-level` | `--level`       | `--severity`               | Minimum severity   |\n| `fix`                   | `--fix`         | `npm audit fix` | N/A             | N/A                        | Auto-fix           |\n\n**Note:**\n\n- pnpm uses `pnpm audit` natively\n- npm uses `npm audit` natively\n- yarn@1 uses `yarn audit` natively\n- yarn@2+ uses `yarn npm audit` via the npm plugin\n- `--prod` flag is mapped differently: pnpm uses `--prod`, npm uses `--omit=dev`, yarn@1 uses `--groups prod`, yarn@2+ uses `--environment production`\n- `audit fix` is only supported by pnpm (via `--fix`) and npm (via `npm audit fix`); yarn does not support it\n\n#### Dist-Tag Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-dist-tag\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/tag\n- https://yarnpkg.com/cli/npm/tag\n\n| Vite+ Flag                       | pnpm                | npm                 | yarn@1          | yarn@2+             | Description       |\n| -------------------------------- | ------------------- | ------------------- | --------------- | ------------------- | ----------------- |\n| `vp pm dist-tag list <pkg>`      | `npm dist-tag list` | `npm dist-tag list` | `yarn tag list` | `yarn npm tag list` | List tags         |\n| `vp pm dist-tag add <pkg> <tag>` | `npm dist-tag add`  | `npm dist-tag add`  | `yarn tag add`  | `yarn npm tag add`  | Add tag           |\n| `vp pm dist-tag rm <pkg> <tag>`  | `npm dist-tag rm`   | `npm dist-tag rm`   | `yarn tag rm`   | `yarn npm tag rm`   | Remove tag        |\n| `--otp <otp>`                    | `--otp`             | `--otp`             | `--otp`         | `--otp`             | One-time password |\n\n**Note:**\n\n- pnpm delegates to `npm dist-tag` for tag management\n- npm uses `npm dist-tag` natively\n- yarn@1 uses `yarn tag` instead of `dist-tag`\n- yarn@2+ uses `yarn npm tag` via the npm plugin\n\n#### Deprecate Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-deprecate\n\n| Vite+ Flag                    | pnpm            | npm             | yarn@1          | yarn@2+         | Description         |\n| ----------------------------- | --------------- | --------------- | --------------- | --------------- | ------------------- |\n| `vp pm deprecate <pkg> <msg>` | `npm deprecate` | `npm deprecate` | `npm deprecate` | `npm deprecate` | Deprecate a package |\n| `--otp <otp>`                 | `--otp`         | `--otp`         | `--otp`         | `--otp`         | One-time password   |\n| `--registry <url>`            | `--registry`    | `--registry`    | `--registry`    | `--registry`    | Registry URL        |\n\n**Note:**\n\n- All package managers delegate to `npm deprecate` since deprecation is an npm registry feature\n- Pass an empty message to un-deprecate a package version\n\n#### Search Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-search\n\n| Vite+ Flag             | pnpm            | npm             | yarn@1          | yarn@2+         | Description         |\n| ---------------------- | --------------- | --------------- | --------------- | --------------- | ------------------- |\n| `vp pm search <terms>` | `npm search`    | `npm search`    | `npm search`    | `npm search`    | Search for packages |\n| `--json`               | `--json`        | `--json`        | `--json`        | `--json`        | JSON output         |\n| `--long`               | `--long`        | `--long`        | `--long`        | `--long`        | Extended info       |\n| `--searchlimit <n>`    | `--searchlimit` | `--searchlimit` | `--searchlimit` | `--searchlimit` | Limit results       |\n\n**Note:**\n\n- All package managers delegate to `npm search` since search is an npm registry feature\n\n#### Rebuild Command\n\n**pnpm references:**\n\n- https://pnpm.io/cli/rebuild\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-rebuild\n\n| Vite+ Flag               | pnpm                 | npm                 | yarn@1     | yarn@2+    | Description           |\n| ------------------------ | -------------------- | ------------------- | ---------- | ---------- | --------------------- |\n| `vp pm rebuild`          | `pnpm rebuild`       | `npm rebuild`       | N/A (warn) | N/A (warn) | Rebuild native addons |\n| `vp pm rebuild <pkg...>` | `pnpm rebuild <pkg>` | `npm rebuild <pkg>` | N/A (warn) | N/A (warn) | Rebuild specific pkgs |\n\n**Note:**\n\n- pnpm uses `pnpm rebuild` natively\n- npm uses `npm rebuild` natively\n- yarn@1 does not have a `rebuild` command (prints warning and ignores)\n- yarn@2+ does not have a `rebuild` command (prints warning and ignores)\n- Packages to rebuild can be specified as positional arguments\n\n#### Fund Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-fund\n\n| Vite+ Flag         | pnpm       | npm        | yarn@1     | yarn@2+    | Description           |\n| ------------------ | ---------- | ---------- | ---------- | ---------- | --------------------- |\n| `vp pm fund`       | `npm fund` | `npm fund` | N/A (warn) | N/A (warn) | Show funding info     |\n| `vp pm fund <pkg>` | `npm fund` | `npm fund` | N/A (warn) | N/A (warn) | Fund for specific pkg |\n| `--json`           | `--json`   | `--json`   | N/A        | N/A        | JSON output           |\n| `--depth <n>`      | `--depth`  | `--depth`  | N/A        | N/A        | Limit depth           |\n\n**Note:**\n\n- All package managers delegate to `npm fund` since funding is an npm-specific feature\n- yarn@1 does not have a `fund` command (prints warning and ignores)\n- yarn@2+ does not have a `fund` command (prints warning and ignores)\n\n#### Ping Command\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-ping\n\n| Vite+ Flag         | pnpm         | npm          | yarn@1       | yarn@2+      | Description   |\n| ------------------ | ------------ | ------------ | ------------ | ------------ | ------------- |\n| `vp pm ping`       | `npm ping`   | `npm ping`   | `npm ping`   | `npm ping`   | Ping registry |\n| `--registry <url>` | `--registry` | `--registry` | `--registry` | `--registry` | Registry URL  |\n\n**Note:**\n\n- All package managers delegate to `npm ping` since registry ping is an npm-specific feature\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command group:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Package manager utilities\n    #[command(disable_help_flag = true, subcommand)]\n    Pm(PmCommands),\n}\n\n#[derive(Subcommand, Debug)]\npub enum PmCommands {\n    /// Remove unnecessary packages\n    Prune {\n        /// Remove devDependencies\n        #[arg(long)]\n        prod: bool,\n\n        /// Remove optional dependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Create a tarball of the package\n    Pack {\n        /// Preview without creating tarball\n        #[arg(long)]\n        dry_run: bool,\n\n        /// Output directory for tarball\n        #[arg(long)]\n        pack_destination: Option<String>,\n\n        /// Gzip compression level (0-9)\n        #[arg(long)]\n        pack_gzip_level: Option<u8>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// List installed packages\n    #[command(alias = \"ls\")]\n    List {\n        /// Package pattern to filter\n        pattern: Option<String>,\n\n        /// Maximum depth of dependency tree\n        #[arg(long)]\n        depth: Option<u32>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Show extended information\n        #[arg(long)]\n        long: bool,\n\n        /// Parseable output format\n        #[arg(long)]\n        parseable: bool,\n\n        /// Only production dependencies\n        #[arg(long)]\n        prod: bool,\n\n        /// Only dev dependencies\n        #[arg(long)]\n        dev: bool,\n\n        /// List across all workspaces\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo (pnpm)\n        #[arg(long)]\n        filter: Vec<String>,\n\n        /// Target specific workspace (npm)\n        #[arg(long)]\n        workspace: Vec<String>,\n\n        /// List global packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// View package information from the registry\n    View {\n        /// Package name with optional version\n        package: String,\n\n        /// Specific field to view\n        field: Option<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Publish package to registry\n    Publish {\n        /// Tarball or folder to publish\n        target: Option<String>,\n\n        /// Preview without publishing\n        #[arg(long)]\n        dry_run: bool,\n\n        /// Publish tag (default: latest)\n        #[arg(long)]\n        tag: Option<String>,\n\n        /// Access level (public/restricted)\n        #[arg(long)]\n        access: Option<String>,\n\n        /// Skip git checks (pnpm)\n        #[arg(long)]\n        no_git_checks: bool,\n\n        /// Force publish\n        #[arg(long)]\n        force: bool,\n\n        /// Publish all workspace packages\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo (pnpm)\n        #[arg(long)]\n        filter: Vec<String>,\n\n        /// Target specific workspace (npm)\n        #[arg(long)]\n        workspace: Vec<String>,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Manage package owners\n    Owner {\n        /// Subcommand: list, add, rm\n        #[command(subcommand)]\n        command: OwnerCommands,\n    },\n\n    /// Manage package cache\n    Cache {\n        /// Subcommand: dir, path, clean, clear, verify, list\n        subcommand: Option<String>,\n\n        /// Force clean (npm)\n        #[arg(long)]\n        force: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n\n    /// Manage package manager configuration\n    Config {\n        /// Subcommand: list, get, set, delete\n        subcommand: Option<String>,\n\n        /// Config key\n        key: Option<String>,\n\n        /// Config value (for set)\n        value: Option<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Use global config\n        #[arg(long)]\n        global: bool,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n}\n\n#[derive(Subcommand, Debug)]\npub enum OwnerCommands {\n    /// List package owners\n    List {\n        /// Package name\n        package: String,\n    },\n\n    /// Add package owner\n    Add {\n        /// Username\n        user: String,\n        /// Package name\n        package: String,\n    },\n\n    /// Remove package owner\n    Rm {\n        /// Username\n        user: String,\n        /// Package name\n        package: String,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/commands/pm.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\nimpl PackageManager {\n    /// Run a pm subcommand with pass-through arguments.\n    #[must_use]\n    pub async fn run_pm_command(\n        &self,\n        subcommand: &str,\n        args: &[String],\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_pm_command(subcommand, args);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve pm command with minimal processing.\n    /// Most arguments are passed through directly to the package manager.\n    #[must_use]\n    pub fn resolve_pm_command(&self, subcommand: &str, args: &[String]) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut cmd_args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n\n                // Map vp pm commands to pnpm commands\n                match subcommand {\n                    \"prune\" => cmd_args.push(\"prune\".into()),\n                    \"pack\" => cmd_args.push(\"pack\".into()),\n                    \"list\" | \"ls\" => cmd_args.push(\"list\".into()),\n                    \"view\" => cmd_args.push(\"view\".into()),\n                    \"publish\" => cmd_args.push(\"publish\".into()),\n                    \"owner\" => cmd_args.push(\"owner\".into()),\n                    \"cache\" => {\n                        // Map cache subcommands\n                        if !args.is_empty() {\n                            match args[0].as_str() {\n                                \"dir\" | \"path\" => cmd_args.push(\"store\".into()),\n                                \"clean\" | \"clear\" => {\n                                    cmd_args.push(\"store\".into());\n                                    cmd_args.push(\"prune\".into());\n                                    return ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs };\n                                }\n                                \"list\" => {\n                                    cmd_args.push(\"store\".into());\n                                    cmd_args.push(\"list\".into());\n                                    return ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs };\n                                }\n                                _ => cmd_args.push(\"store\".into()),\n                            }\n                        } else {\n                            cmd_args.push(\"store\".into());\n                            cmd_args.push(\"path\".into());\n                            return ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs };\n                        }\n                    }\n                    \"config\" => cmd_args.push(\"config\".into()),\n                    _ => cmd_args.push(subcommand.into()),\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n\n                match subcommand {\n                    \"prune\" => {\n                        eprintln!(\"Warning: npm removed 'prune' command in v6. Use 'vp install --prod' instead.\");\n                        return ResolveCommandResult {\n                            bin_path: \"echo\".into(),\n                            args: vec![\"npm prune is deprecated\".into()],\n                            envs,\n                        };\n                    }\n                    \"pack\" => cmd_args.push(\"pack\".into()),\n                    \"list\" | \"ls\" => cmd_args.push(\"list\".into()),\n                    \"view\" => cmd_args.push(\"view\".into()),\n                    \"publish\" => cmd_args.push(\"publish\".into()),\n                    \"owner\" => cmd_args.push(\"owner\".into()),\n                    \"cache\" => {\n                        cmd_args.push(\"cache\".into());\n                        if !args.is_empty() {\n                            match args[0].as_str() {\n                                \"path\" => {\n                                    // npm uses 'dir' not 'path'\n                                    cmd_args.push(\"dir\".into());\n                                    return ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs };\n                                }\n                                \"clear\" => {\n                                    // npm uses 'clean' not 'clear'\n                                    cmd_args.push(\"clean\".into());\n                                }\n                                _ => {}\n                            }\n                        }\n                    }\n                    \"config\" => cmd_args.push(\"config\".into()),\n                    _ => cmd_args.push(subcommand.into()),\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n\n                match subcommand {\n                    \"prune\" => {\n                        if self.version.starts_with(\"1.\") {\n                            cmd_args.push(\"prune\".into());\n                        } else {\n                            eprintln!(\"Warning: yarn@2+ does not have 'prune' command\");\n                            return ResolveCommandResult {\n                                bin_path: \"echo\".into(),\n                                args: vec![\"yarn@2+ does not support prune\".into()],\n                                envs,\n                            };\n                        }\n                    }\n                    \"pack\" => cmd_args.push(\"pack\".into()),\n                    \"list\" | \"ls\" => cmd_args.push(\"list\".into()),\n                    \"view\" => {\n                        // yarn uses 'info' instead of 'view'\n                        cmd_args.push(\"info\".into());\n                    }\n                    \"publish\" => {\n                        if self.version.starts_with(\"1.\") {\n                            cmd_args.push(\"publish\".into());\n                        } else {\n                            cmd_args.push(\"npm\".into());\n                            cmd_args.push(\"publish\".into());\n                        }\n                    }\n                    \"owner\" => {\n                        if self.version.starts_with(\"1.\") {\n                            cmd_args.push(\"owner\".into());\n                        } else {\n                            cmd_args.push(\"npm\".into());\n                            cmd_args.push(\"owner\".into());\n                        }\n                    }\n                    \"cache\" => {\n                        cmd_args.push(\"cache\".into());\n                        if !args.is_empty() {\n                            match args[0].as_str() {\n                                \"path\" => {\n                                    // yarn uses 'dir' not 'path'\n                                    cmd_args.push(\"dir\".into());\n                                    return ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs };\n                                }\n                                \"clear\" => {\n                                    // yarn uses 'clean' not 'clear'\n                                    cmd_args.push(\"clean\".into());\n                                }\n                                \"verify\" => {\n                                    eprintln!(\"Warning: yarn does not support 'cache verify'\");\n                                    return ResolveCommandResult {\n                                        bin_path: \"echo\".into(),\n                                        args: vec![\"yarn does not support cache verify\".into()],\n                                        envs,\n                                    };\n                                }\n                                _ => {}\n                            }\n                        }\n                    }\n                    \"config\" => {\n                        cmd_args.push(\"config\".into());\n                        // yarn@2+ uses different config commands\n                        if !self.version.starts_with(\"1.\") && !args.is_empty() && args[0] == \"delete\" {\n                            cmd_args.push(\"unset\".into());\n                            cmd_args.extend_from_slice(&args[1..]);\n                            return ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs };\n                        }\n                    }\n                    _ => cmd_args.push(subcommand.into()),\n                }\n            }\n        }\n\n        // Pass through all remaining arguments\n        cmd_args.extend_from_slice(args);\n\n        ResolveCommandResult { bin_path: bin_name, args: cmd_args, envs }\n    }\n}\n```\n\n**File**: `crates/vite_package_manager/src/commands/mod.rs`\n\nUpdate to include pm module:\n\n```rust\npub mod add;\nmod install;\npub mod remove;\npub mod update;\npub mod link;\npub mod unlink;\npub mod dedupe;\npub mod why;\npub mod outdated;\npub mod pm;  // Add this line\n```\n\n#### 3. PM Command Implementation\n\n**File**: `crates/vite_task/src/pm.rs` (new file)\n\n```rust\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_package_manager::PackageManager;\nuse vite_workspace::Workspace;\n\npub struct PmCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl PmCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        subcommand: String,\n        args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        let exit_status = package_manager\n            .run_pm_command(&subcommand, &args, &workspace.root)\n            .await?;\n\n        if !exit_status.success() {\n            return Err(Error::CommandFailed {\n                command: format!(\"pm {}\", subcommand),\n                exit_code: exit_status.code(),\n            });\n        }\n\n        workspace.unload().await?;\n\n        Ok(ExecutionSummary::default())\n    }\n}\n```\n\n## Design Decisions\n\n### 1. Pass-Through Architecture\n\n**Decision**: Use minimal processing and pass most arguments directly to package managers.\n\n**Rationale**:\n\n- Package managers have many flags and options that change frequently\n- Trying to map every option is maintenance-intensive and error-prone\n- Pass-through allows users to use any package manager feature\n- Vite+ provides the abstraction of which PM to use, not feature mapping\n- Users can reference their package manager docs for advanced options\n\n### 2. Command Name Mapping\n\n**Decision**: Map common command name differences (e.g., `view` → `info` for yarn).\n\n**Rationale**:\n\n- Some commands have different names across package managers\n- Basic name mapping provides better UX\n- Keeps common cases simple\n- Advanced users can still use native commands directly\n\n### 3. Cache Command Special Handling\n\n**Decision**: Provide subcommands for cache (dir, clean, verify, list).\n\n**Rationale**:\n\n- Cache commands have very different syntax across package managers\n- pnpm uses `store`, npm uses `cache`, yarn uses `cache`\n- Unified interface makes cache management easier\n- Common operation that benefits from abstraction\n\n### 4. No Caching\n\n**Decision**: Don't cache any pm command results.\n\n**Rationale**:\n\n- PM utilities query current state or modify configuration\n- Caching would provide stale data\n- Operations are fast enough without caching\n- Real-time data is expected\n\n### 5. Deprecation Warnings\n\n**Decision**: Warn users when commands aren't available in their package manager.\n\n**Rationale**:\n\n- npm removed `prune` in v6\n- yarn@2+ doesn't have `prune`\n- Helpful to educate users about alternatives\n- Better than silent failure\n\n### 6. Subcommand Groups\n\n**Decision**: Group related commands under `pm` rather than top-level commands.\n\n**Rationale**:\n\n- Keeps Vite+ CLI namespace clean\n- Clear categorization (pm utilities vs task running)\n- Matches Bun's design pattern\n- Extensible for future utilities\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vp pm list\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### Unsupported Command\n\n```bash\n$ vp pm prune\nDetected package manager: yarn@4.0.0\nWarning: yarn does not have 'prune' command. yarn install will prune extraneous packages automatically.\n$ echo $?\n0\n```\n\n### Command Failed\n\n```bash\n$ vp pm publish\nDetected package manager: pnpm@10.15.0\nRunning: pnpm publish\nError: You must be logged in to publish packages\nExit code: 1\n```\n\n## User Experience\n\n### Prune Packages\n\n```bash\n$ vp pm prune\nDetected package manager: pnpm@10.15.0\nRunning: pnpm prune\nPackages: -12\n\n$ vp pm prune --prod\nDetected package manager: npm@11.0.0\nRunning: npm prune --omit=dev\nremoved 45 packages\n```\n\n### Cache Management\n\n```bash\n$ vp pm cache dir\nDetected package manager: pnpm@10.15.0\nRunning: pnpm store path\n/Users/user/Library/pnpm/store\n\n$ vp pm cache clean\nDetected package manager: pnpm@10.15.0\nRunning: pnpm store prune\nRemoved 145 packages\n```\n\n### List Packages\n\n```bash\n$ vp pm list --depth 0\nDetected package manager: pnpm@10.15.0\nRunning: pnpm list --depth 0\n\nmy-app@1.0.0\n├── react@18.3.1\n├── react-dom@18.3.1\n└── lodash@4.17.21\n```\n\n### View Package\n\n```bash\n$ vp pm view react version\nDetected package manager: npm@11.0.0\nRunning: npm view react version\n18.3.1\n```\n\n### Publish Package\n\n```bash\n$ vp pm publish --dry-run\nDetected package manager: pnpm@10.15.0\nRunning: pnpm publish --dry-run\n\nnpm notice\nnpm notice package: my-package@1.0.0\nnpm notice === Tarball Contents ===\nnpm notice 1.2kB package.json\nnpm notice 2.3kB README.md\nnpm notice === Tarball Details ===\nnpm notice name:          my-package\nnpm notice version:       1.0.0\n```\n\n### Configuration\n\n```bash\n$ vp pm config get registry\nDetected package manager: pnpm@10.15.0\nRunning: pnpm config get registry\nhttps://registry.npmjs.org\n\n$ vp pm config set registry https://custom-registry.com\nDetected package manager: pnpm@10.15.0\nRunning: pnpm config set registry https://custom-registry.com\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Individual Top-Level Commands\n\n```bash\nvp cache dir\nvp publish\nvp pack\n```\n\n**Rejected because**:\n\n- Clutters top-level namespace\n- Mixes task running with PM utilities\n- Less clear categorization\n- Harder to discover related commands\n\n### Alternative 2: Full Flag Mapping\n\n```bash\n# Try to map all package manager flags\nvp pm list --production  # Map to --prod (pnpm), --production (npm)\n```\n\n**Rejected because**:\n\n- Maintenance burden as PMs add/change flags\n- Incomplete mapping would be confusing\n- Pass-through is more flexible\n- Users can refer to PM docs for advanced usage\n\n### Alternative 3: Single Pass-Through Command\n\n```bash\nvp pm -- pnpm store path\nvp pm -- npm cache dir\n```\n\n**Rejected because**:\n\n- Loses abstraction benefit\n- User must know package manager\n- No command name translation\n- Defeats purpose of unified interface\n\n## Implementation Plan\n\n### Phase 1: Core Infrastructure\n\n1. Add `Pm` command group to `Commands` enum\n2. Create `pm.rs` module in vite_package_manager\n3. Implement basic pass-through for each subcommand\n4. Add command name mapping (view → info, etc.)\n\n### Phase 2: Subcommands\n\n1. Implement `prune` with deprecation warnings\n2. Implement `pack` with options\n3. Implement `list/ls` with filtering\n4. Implement `view` with field selection\n5. Implement `publish` with workspace support\n6. Implement `owner` subcommands\n7. Implement `cache` with subcommands\n8. Implement `config` with subcommands\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Test pass-through arguments\n3. Test command name mapping\n4. Test deprecation warnings\n5. Integration tests with mock package managers\n6. Test workspace operations\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples for each subcommand\n3. Document package manager compatibility\n4. Add troubleshooting guide\n\n## Testing Strategy\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_cache_dir() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let result = pm.resolve_pm_command(\"cache\", &[\"dir\".to_string()]);\n    assert_eq!(result.args, vec![\"store\", \"path\"]);\n}\n\n#[test]\nfn test_npm_cache_dir() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let result = pm.resolve_pm_command(\"cache\", &[\"dir\".to_string()]);\n    assert_eq!(result.args, vec![\"cache\", \"dir\"]);\n}\n\n#[test]\nfn test_yarn_view_maps_to_info() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let result = pm.resolve_pm_command(\"view\", &[\"react\".to_string()]);\n    assert_eq!(result.args, vec![\"info\", \"react\"]);\n}\n\n#[test]\nfn test_pass_through_args() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let result = pm.resolve_pm_command(\"list\", &[\"--depth\".to_string(), \"0\".to_string()]);\n    assert_eq!(result.args, vec![\"list\", \"--depth\", \"0\"]);\n}\n```\n\n## CLI Help Output\n\n```bash\n$ vp pm --help\nPackage manager utilities\n\nUsage: vp pm <COMMAND>\n\nCommands:\n  prune      Remove unnecessary packages\n  pack       Create a tarball of the package\n  list       List installed packages (alias: ls)\n  view       View package information from the registry\n  publish    Publish package to registry\n  owner      Manage package owners\n  cache      Manage package cache\n  config     Manage package manager configuration\n  login      Log in to the registry\n  logout     Log out from the registry\n  whoami     Show the currently logged-in user\n  token      Manage registry authentication tokens\n  audit      Run a security audit on installed packages\n  dist-tag   Manage distribution tags on packages\n  deprecate  Deprecate a version of a package\n  search     Search the registry for packages\n  rebuild    Rebuild native addons\n  fund       Show funding information for installed packages\n  ping       Ping the registry\n  help       Print this message or the help of the given subcommand(s)\n\nOptions:\n  -h, --help  Print help\n\n$ vp pm cache --help\nManage package cache\n\nUsage: vp pm cache [SUBCOMMAND] [OPTIONS]\n\nSubcommands:\n  dir      Show cache directory (alias: path)\n  path     Alias for dir\n  clean    Clean cache (alias: clear)\n  clear    Alias for clean\n  verify   Verify cache integrity (npm only)\n  list     List cached packages (pnpm only)\n\nOptions:\n  --force              Force cache clean (npm only)\n  -h, --help           Print help\n\nExamples:\n  vp pm cache dir              # Show cache directory\n  vp pm cache clean            # Clean cache\n  vp pm cache clean --force    # Force clean (npm)\n  vp pm cache verify           # Verify cache (npm)\n  vp pm cache list             # List cached packages (pnpm)\n```\n\n## Package Manager Compatibility\n\n| Subcommand | pnpm       | npm     | yarn@1     | yarn@2+          | Notes                                   |\n| ---------- | ---------- | ------- | ---------- | ---------------- | --------------------------------------- |\n| prune      | ✅ Full    | ✅ Full | ❌ N/A     | ❌ N/A           | npm uses --omit flags, yarn auto-prunes |\n| pack       | ✅ Full    | ✅ Full | ✅ Full    | ✅ Full          | All supported                           |\n| list/ls    | ✅ Full    | ✅ Full | ⚠️ Limited | ❌ N/A           | yarn@1 no -r, yarn@2+ not supported     |\n| view       | ✅ Full    | ✅ Full | ⚠️ `info`  | ⚠️ `info`        | yarn uses different name                |\n| publish    | ✅ Full    | ✅ Full | ✅ Full    | ⚠️ `npm publish` | yarn@2+ uses npm plugin                 |\n| owner      | ✅ Full    | ✅ Full | ✅ Full    | ⚠️ `npm owner`   | yarn@2+ uses npm plugin                 |\n| cache      | ⚠️ `store` | ✅ Full | ✅ Full    | ✅ Full          | pnpm uses different command             |\n| config     | ✅ Full    | ✅ Full | ✅ Full    | ⚠️ Different     | yarn@2+ has different API               |\n| login      | ✅ `npm`   | ✅ Full | ✅ Full    | ⚠️ `npm login`   | pnpm delegates to npm                   |\n| logout     | ✅ `npm`   | ✅ Full | ✅ Full    | ⚠️ `npm logout`  | pnpm delegates to npm                   |\n| whoami     | ✅ `npm`   | ✅ Full | ❌ N/A     | ⚠️ `npm whoami`  | yarn@1 not supported                    |\n| token      | ✅ `npm`   | ✅ Full | ❌ N/A     | ❌ N/A           | Always delegates to npm                 |\n| audit      | ✅ Full    | ✅ Full | ✅ Full    | ⚠️ `npm audit`   | yarn@2+ uses npm plugin                 |\n| dist-tag   | ✅ `npm`   | ✅ Full | ⚠️ `tag`   | ⚠️ `npm tag`     | Different command names                 |\n| deprecate  | ✅ `npm`   | ✅ Full | ✅ `npm`   | ✅ `npm`         | Always delegates to npm                 |\n| search     | ✅ `npm`   | ✅ Full | ✅ `npm`   | ✅ `npm`         | Always delegates to npm                 |\n| rebuild    | ✅ Full    | ✅ Full | ❌ N/A     | ❌ N/A           | yarn does not support                   |\n| fund       | ✅ `npm`   | ✅ Full | ❌ N/A     | ❌ N/A           | Always delegates to npm                 |\n| ping       | ✅ `npm`   | ✅ Full | ✅ `npm`   | ✅ `npm`         | Always delegates to npm                 |\n\n## Future Enhancements\n\n### 1. Interactive Cache Management\n\n```bash\nvp pm cache --interactive\n# Shows cache size, allows selective cleaning\n```\n\n### 2. Publish Dry-Run Summary\n\n```bash\nvp pm publish --dry-run --summary\n# Shows what would be published with sizes\n```\n\n### 3. Config Validation\n\n```bash\nvp pm config validate\n# Checks configuration for issues\n```\n\n### 4. Owner Management UI\n\n```bash\nvp pm owner --interactive my-package\n# Interactive UI for adding/removing owners\n```\n\n### 5. Cache Analytics\n\n```bash\nvp pm cache stats\n# Shows cache usage statistics, size breakdown\n```\n\n## Security Considerations\n\n1. **Publish Safety**: Dry-run option allows preview before publishing\n2. **Config Isolation**: Respects package manager's configuration hierarchy\n3. **Owner Management**: Delegates to package manager's authentication\n4. **Cache Integrity**: Verify option (npm) checks for corruption\n5. **Pass-Through Safety**: Arguments are passed through shell-escaped\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New command group is additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Real-World Usage Examples\n\n### Cache Management in CI\n\n```yaml\n# Clean cache before build\n- run: vp pm cache clean --force\n\n# Show cache location for debugging\n- run: vp pm cache dir\n```\n\n### Publishing Workflow\n\n```bash\n# Build packages\nvp build -r\n\n# Dry run to verify\nvp pm publish --dry-run -r\n\n# Publish with beta tag\nvp pm publish --tag beta -r\n\n# Publish only specific packages\nvp pm publish --filter app\n```\n\n### Configuration Management\n\n```bash\n# Set custom registry\nvp pm config set registry https://custom-registry.com\n\n# Verify configuration\nvp pm config get registry\n\n# List all configuration\nvp pm config list\n```\n\n### Dependency Auditing\n\n```bash\n# List dependencies to JSON file\nvp pm list --json > deps.json\n\n# List production dependencies\nvp pm list --prod\n\n# List specific workspace\nvp pm list --filter app\n```\n\n## Conclusion\n\nThis RFC proposes adding `vp pm` command group to provide unified access to package manager utilities across pnpm/npm/yarn. The design:\n\n- ✅ Pass-through architecture for maximum flexibility\n- ✅ Command name translation for common operations\n- ✅ Unified cache management interface\n- ✅ Support for all major package managers\n- ✅ Workspace-aware operations\n- ✅ Deprecation warnings for removed commands\n- ✅ Extensible for future enhancements\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ Matches Bun's pm command design pattern\n\nThe implementation follows the same patterns as other package management commands while providing direct access to package manager utilities that developers need for publishing, cache management, configuration, and more.\n"
  },
  {
    "path": "rfcs/run-without-vite-plus-dependency.md",
    "content": "# RFC: `vp run` Without vite-plus Dependency\n\n## Summary\n\nAllow `vp run <script>` to work in projects that do not have `vite-plus` as a dependency. When vite-plus is not found in the nearest `package.json`'s `dependencies` or `devDependencies`, fall back to executing `<package-manager> run <script> [args...]` directly from the Rust layer, bypassing the JS delegation entirely.\n\n## Motivation\n\nCurrently, all Category C commands (`vp run`, `vp build`, `vp test`, `vp lint`, `vp dev`, `vp fmt`, `vp preview`, `vp cache`) delegate to the local vite-plus CLI via the JS layer. When vite-plus is not installed as a dependency, `packages/global/src/local/bin.ts` prompts the user to add it to devDependencies:\n\n```\nLocal \"vite-plus\" package was not found\n? Do you want to add vite-plus to devDependencies? (Y/n)\n```\n\nIf the user declines, it exits with:\n\n```\nPlease add vite-plus to devDependencies first\n```\n\nThis creates several issues:\n\n1. **Barrier to adoption**: Users who install `vp` globally cannot use `vp run dev` as a drop-in replacement for `pnpm run dev` without first adding vite-plus to their project\n2. **Unnecessary overhead**: The current flow downloads a Node.js runtime and enters the JS layer just to discover that vite-plus is missing\n3. **Friction in existing projects**: Projects that want to use `vp` for its managed Node.js runtime and package manager features (install, add, remove) but not the task runner are blocked from using `vp run`\n\n### Current Pain Points\n\n```bash\n# User installs vp globally and tries to use it\n$ vp run dev\nLocal \"vite-plus\" package was not found\n? Do you want to add vite-plus to devDependencies? (Y/n) n\nPlease add vite-plus to devDependencies first\n\n# User just wants the equivalent of:\n$ pnpm run dev\n```\n\n### Proposed Solution\n\n```bash\n# Without vite-plus as a dependency, falls back to PM run\n$ vp run dev\n# Executes: pnpm run dev (or npm/yarn depending on project)\n\n# With vite-plus as a dependency, uses full task runner\n$ vp run build -r\n# Executes via vite-plus task runner with recursive + topological ordering\n```\n\n## Proposed Solution\n\n### Detection Logic\n\nCheck the nearest `package.json` from the current working directory for `vite-plus` in `dependencies` or `devDependencies`. This matches the existing JS-side behavior in `hasVitePlusDependency(readNearestPackageJson(cwd))`.\n\n**Decision: Check `package.json` only, NOT `node_modules`**\n\n- If vite-plus is listed in `package.json` but not installed, the user must run `install` manually\n- If vite-plus is NOT listed in `package.json`, we fall back to PM run\n- Checking `node_modules` would be fragile (hoisted deps, workspaces) and inconsistent with the intent\n\n### Scope\n\nThis RFC applies **only to `vp run`**. Other Category C commands (`build`, `test`, `lint`, `dev`, `fmt`, `preview`, `cache`) are vite-plus specific features and do not have natural PM fallbacks:\n\n- `vp build` = Vite build (not `pnpm build`)\n- `vp test` = Vitest (not `pnpm test`)\n- `vp lint` = OxLint (not `pnpm lint`)\n\nThese should continue to delegate to the local CLI and prompt for vite-plus installation.\n\n### Command Mapping\n\nWhen falling back to PM run, all arguments are passed through as-is:\n\n| `vp run` invocation      | Fallback command           | Notes                    |\n| ------------------------ | -------------------------- | ------------------------ |\n| `vp run dev`             | `pnpm run dev`             | Basic script execution   |\n| `vp run dev --port 3000` | `pnpm run dev --port 3000` | Args passed through      |\n| `vp run build -r`        | `pnpm run build -r`        | PM ignores unknown flags |\n| `vp run app#build`       | `pnpm run app#build`       | PM treats as script name |\n\nvite-plus specific flags (`-r`, `--recursive`, `--topological`, `package#task` syntax) are only meaningful when vite-plus is installed. When falling back, these are passed verbatim to the PM which will naturally error with \"Missing script\" -- this is correct behavior since these features require vite-plus.\n\n### Architecture: Rust-Side Fallback\n\nThe fallback is implemented in the Rust layer, **before** entering the JS delegation flow. This avoids the unnecessary overhead of downloading Node.js runtime and entering the JS layer.\n\n```\n                  vp run <args>\n                       |\n               has_vite_plus_dependency(cwd)?\n                   /        \\\n                 yes         no\n                  |           |\n          delegate to JS     build PM\n          (existing flow)      |\n                          <pm> run <args>\n```\n\n## Implementation Architecture\n\n### 1. Dependency Check Utility\n\n**File**: `crates/vite_global_cli/src/commands/mod.rs`\n\nA utility function that walks up from `cwd` to find the nearest `package.json` and checks for vite-plus:\n\n```rust\nuse std::collections::HashMap;\nuse std::io::BufReader;\n\n#[derive(serde::Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\nstruct DepCheckPackageJson {\n    #[serde(default)]\n    dependencies: HashMap<String, serde_json::Value>,\n    #[serde(default)]\n    dev_dependencies: HashMap<String, serde_json::Value>,\n}\n\n/// Check if vite-plus is listed in the nearest package.json's\n/// dependencies or devDependencies.\n///\n/// Returns `true` if vite-plus is found, `false` if not found\n/// or if no package.json exists.\npub fn has_vite_plus_dependency(cwd: &AbsolutePath) -> bool {\n    let mut current = cwd;\n    loop {\n        let package_json_path = current.join(\"package.json\");\n        if package_json_path.exists() {\n            if let Ok(file) = std::fs::File::open(&package_json_path) {\n                if let Ok(pkg) = serde_json::from_reader::<_, DepCheckPackageJson>(\n                    BufReader::new(file)\n                ) {\n                    return pkg.dependencies.contains_key(\"vite-plus\")\n                        || pkg.dev_dependencies.contains_key(\"vite-plus\");\n                }\n            }\n            return false; // Found package.json but couldn't parse deps → treat as no dependency\n        }\n        match current.parent() {\n            Some(parent) if parent != current => current = parent,\n            _ => return false, // Reached filesystem root\n        }\n    }\n}\n```\n\n### 2. PM Run Command\n\n**File**: `crates/vite_install/src/commands/run.rs` (new file)\n\nFollowing the established pattern from `dlx.rs`:\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\nuse vite_command::run_command;\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\nuse crate::package_manager::{PackageManager, PackageManagerType, ResolveCommandResult, format_path_env};\n\nimpl PackageManager {\n    /// Run `<pm> run <args>` to execute a package.json script.\n    pub async fn run_script_command(\n        &self,\n        args: &[String],\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_run_script_command(args);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the `<pm> run <args>` command.\n    #[must_use]\n    pub fn resolve_run_script_command(&self, args: &[String]) -> ResolveCommandResult {\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut cmd_args: Vec<String> = vec![\"run\".to_string()];\n        cmd_args.extend(args.iter().cloned());\n\n        let bin_path = match self.client {\n            PackageManagerType::Pnpm => \"pnpm\",\n            PackageManagerType::Npm => \"npm\",\n            PackageManagerType::Yarn => \"yarn\",\n        };\n\n        ResolveCommandResult {\n            bin_path: bin_path.to_string(),\n            args: cmd_args,\n            envs,\n        }\n    }\n}\n```\n\n**Register**: Add `pub mod run;` to `crates/vite_install/src/commands/mod.rs`\n\n### 3. Run-or-Delegate Orchestration\n\n**File**: `crates/vite_global_cli/src/commands/run_or_delegate.rs` (new file)\n\n```rust\n//! Run command with fallback to package manager when vite-plus is not a dependency.\n\nuse std::process::ExitStatus;\nuse vite_path::AbsolutePathBuf;\nuse crate::error::Error;\n\n/// Execute `vp run <args>`.\n///\n/// If vite-plus is a dependency, delegate to the local CLI.\n/// If not, fall back to `<pm> run <args>`.\npub async fn execute(\n    cwd: AbsolutePathBuf,\n    args: &[String],\n) -> Result<ExitStatus, Error> {\n    if super::has_vite_plus_dependency(&cwd) {\n        tracing::debug!(\"vite-plus is a dependency, delegating to local CLI\");\n        super::delegate::execute(cwd, \"run\", args).await\n    } else {\n        tracing::debug!(\"vite-plus is not a dependency, falling back to package manager run\");\n        super::prepend_js_runtime_to_path_env(&cwd).await?;\n        let package_manager = super::build_package_manager(&cwd).await?;\n        Ok(package_manager.run_script_command(args, &cwd).await?)\n    }\n}\n```\n\n### 4. CLI Dispatch Update\n\n**File**: `crates/vite_global_cli/src/cli.rs`, line 1541\n\n```rust\n// Before:\nCommands::Run { args } => commands::delegate::execute(cwd, \"run\", &args).await,\n\n// After:\nCommands::Run { args } => commands::run_or_delegate::execute(cwd, &args).await,\n```\n\n## Design Decisions\n\n### 1. Rust-Side vs JS-Side Fallback\n\n**Decision**: Implement the fallback in the Rust layer.\n\n**Rationale**:\n\n- **Performance**: Avoids downloading Node.js runtime and entering JS layer when unnecessary\n- **Consistency**: Follows the same pattern as other PM commands (install, add, remove) which are Rust-native\n- **Reuse**: Leverages existing `build_package_manager()` and `prepend_js_runtime_to_path_env()` utilities\n\n**Alternative rejected**: Modifying `packages/global/src/local/bin.ts` to add a PM fallback would work but still require downloading Node.js first.\n\n### 2. Check Nearest package.json Only\n\n**Decision**: Walk up from `cwd` to find the nearest `package.json`, check only that file.\n\n**Rationale**:\n\n- Matches the existing JS-side behavior (`readNearestPackageJson(cwd)`)\n- In a monorepo, a sub-package without vite-plus in its own package.json should still fall back even if the root has it -- the user is running from that package's context\n- Simple, predictable behavior\n\n**Alternative rejected**: Checking all ancestor package.json files up to workspace root would be more permissive but inconsistent with JS-side behavior.\n\n### 3. Return `false` When No package.json Found\n\n**Decision**: When no `package.json` exists at all, treat as \"no vite-plus dependency\" and fall back to PM run.\n\n**Rationale**:\n\n- `build_package_manager()` will fail with \"No package.json found\" which is already a clear error\n- No special error handling needed\n- Consistent with PM commands that also require package.json\n\n### 4. Scope Limited to `vp run`\n\n**Decision**: Only `vp run` gets the PM fallback. Other commands (`build`, `test`, `lint`, etc.) continue requiring vite-plus.\n\n**Rationale**:\n\n- `vp run <script>` maps naturally to `<pm> run <script>`\n- `vp build` means \"Vite build\", not `<pm> run build` -- there's no meaningful fallback\n- This keeps the behavior clear and predictable\n\n### 5. Pass-Through All Arguments\n\n**Decision**: All arguments after `run` are passed verbatim to the PM.\n\n**Rationale**:\n\n- Simple implementation with no argument rewriting\n- vite-plus specific flags (`-r`, `package#task`) are meaningless without vite-plus\n- PM will naturally error on unknown flags/scripts\n\n## Error Handling\n\n### No package.json Found\n\n```bash\n$ cd /tmp && vp run dev\nNo package.json found.\n```\n\nThis comes from `build_package_manager()` which is the standard error for all PM commands.\n\n### Script Not Found\n\n```bash\n$ vp run nonexistent\n# Falls back to: pnpm run nonexistent\n ERR_PNPM_NO_SCRIPT  Missing script: nonexistent\n```\n\nStandard PM error, no special handling needed.\n\n### No Package Manager Detected\n\nWhen `package.json` exists but has no `packageManager` field and no lockfiles:\n\n```bash\n$ vp run dev\n# build_package_manager prompts for PM selection (existing behavior)\n```\n\n## User Experience\n\n### Without vite-plus Dependency\n\n```bash\n# package.json has scripts.dev but no vite-plus dependency\n$ vp run dev\n# Detects pnpm (from pnpm-lock.yaml)\n# Executes: pnpm run dev\n> my-app@1.0.0 dev\n> vite\n  VITE v6.0.0  ready in 200ms\n```\n\n### With vite-plus Dependency\n\n```bash\n# package.json has vite-plus in devDependencies\n$ vp run build -r\n# Delegates to local vite-plus CLI\n# Uses task runner with recursive + topological ordering\n  my-lib  build  done in 1.2s\n  my-app  build  done in 2.3s\n```\n\n### Mixed Monorepo\n\n```bash\n# Root package.json has vite-plus, sub-package does not\n/workspace$ vp run dev          # → delegates to vite-plus (found in root deps)\n/workspace/legacy-pkg$ vp run dev  # → falls back to PM run (not in legacy-pkg's deps)\n```\n\n## Testing Strategy\n\n### Unit Tests\n\n**`has_vite_plus_dependency` tests:**\n\n```rust\n#[test]\nfn test_has_vite_plus_in_dev_dependencies() {\n    // package.json: { \"devDependencies\": { \"vite-plus\": \"^1.0.0\" } }\n    // → returns true\n}\n\n#[test]\nfn test_has_vite_plus_in_dependencies() {\n    // package.json: { \"dependencies\": { \"vite-plus\": \"^1.0.0\" } }\n    // → returns true\n}\n\n#[test]\nfn test_no_vite_plus_dependency() {\n    // package.json: { \"devDependencies\": { \"vite\": \"^6.0.0\" } }\n    // → returns false\n}\n\n#[test]\nfn test_no_package_json() {\n    // Empty temp directory\n    // → returns false\n}\n\n#[test]\nfn test_nested_directory_walks_up() {\n    // parent/package.json has vite-plus, cwd is parent/child/\n    // → returns true\n}\n```\n\n**`resolve_run_script_command` tests:**\n\n```rust\n#[test]\nfn test_pnpm_run_script() {\n    let pm = create_mock_package_manager(PackageManagerType::Pnpm, \"10.0.0\");\n    let result = pm.resolve_run_script_command(&[\"dev\".into()]);\n    assert_eq!(result.bin_path, \"pnpm\");\n    assert_eq!(result.args, vec![\"run\", \"dev\"]);\n}\n\n#[test]\nfn test_npm_run_script_with_args() {\n    let pm = create_mock_package_manager(PackageManagerType::Npm, \"11.0.0\");\n    let result = pm.resolve_run_script_command(&[\"dev\".into(), \"--port\".into(), \"3000\".into()]);\n    assert_eq!(result.bin_path, \"npm\");\n    assert_eq!(result.args, vec![\"run\", \"dev\", \"--port\", \"3000\"]);\n}\n\n#[test]\nfn test_yarn_run_script() {\n    let pm = create_mock_package_manager(PackageManagerType::Yarn, \"4.0.0\");\n    let result = pm.resolve_run_script_command(&[\"build\".into()]);\n    assert_eq!(result.bin_path, \"yarn\");\n    assert_eq!(result.args, vec![\"run\", \"build\"]);\n}\n```\n\n## Backward Compatibility\n\n- **Projects with vite-plus**: No change in behavior. The `has_vite_plus_dependency` check passes, and delegation proceeds as before.\n- **Projects without vite-plus**: Previously errored or prompted for installation. Now works by falling back to PM run.\n- **No breaking changes**: This is strictly additive behavior.\n\n## Future Enhancements\n\n### 1. Extend to Other Commands\n\nIf demand exists, other commands could gain PM fallbacks:\n\n```bash\nvp dev   → <pm> run dev   (when no vite-plus)\nvp build → <pm> run build (when no vite-plus)\n```\n\nThis would require a separate RFC and careful consideration of when `vp build` should mean \"Vite build\" vs \"run the build script\".\n\n### 2. Informational Message\n\nOptionally show a message when falling back:\n\n```bash\n$ vp run dev\n(vite-plus not found, using pnpm run)\n> my-app@1.0.0 dev\n```\n\nThis could be controlled by a `--verbose` flag or shown only once.\n\n## Files Changed\n\n| File                                                     | Action | Description                                                          |\n| -------------------------------------------------------- | ------ | -------------------------------------------------------------------- |\n| `crates/vite_global_cli/src/commands/mod.rs`             | Modify | Add `has_vite_plus_dependency()` + register `run_or_delegate` module |\n| `crates/vite_global_cli/src/commands/run_or_delegate.rs` | Create | Orchestration: check deps, delegate or fallback                      |\n| `crates/vite_install/src/commands/mod.rs`                | Modify | Register `pub mod run;`                                              |\n| `crates/vite_install/src/commands/run.rs`                | Create | PM `run` command resolution                                          |\n| `crates/vite_global_cli/src/cli.rs`                      | Modify | Update dispatch at line 1541                                         |\n"
  },
  {
    "path": "rfcs/split-global-cli.md",
    "content": "# RFC: Split Global CLI\n\n## Background\n\nThe global CLI is a single binary that combines all the functionality of the vite-plus toolchain. It is a convenient way to get started with vite-plus, but it is also a large binary that is difficult to maintain.\n\n## Goals\n\n1. Split the global CLI into independent package and reduce size\n2. Only include the necessary commands in the global CLI: generate, migration, and package manager commands\n3. Delegate all other commands to the local CLI: lint, fmt, build, test, lib, doc, etc.\n\n## User Stories\n\nInstall the global CLI first\n\n```bash\nnpm install -g @voidzero-dev/global\n```\n\n### Global CLI Commands\n\n```bash\nvp --version\nvp --help\nvp create --help\n```\n\nGenerate a new project\n\n```bash\nvp create\n```\n\nMigrate an existing project\n\n```bash\nvp migration\n```\n\nAdd a package to the project\n\n```bash\nvp add vue\n```\n\n### Delegate to local CLI Commands\n\nAll the other commands are delegated to the local CLI.\nIf the local CLI is not installed, the global CLI will install it for you.\n\n```bash\nvp run build\n\n# if not installed, will install it for you\nAdd vite-plus as a devDependency in package.json? [y/N]: y\n\n# will install it for you\nInstalling vite-plus...\n\n# will run the build task\nvp run build\n```\n\nFormat the project\n\n```bash\nvp fmt\n```\n\nLint the project\n\n```bash\nvp lint\n```\n\nTest the project\n\n```bash\nvp test\n```\n\nRun a build task\n\n```bash\nvp run build\n```\n"
  },
  {
    "path": "rfcs/trampoline-exe-for-shims.md",
    "content": "# RFC: Windows Trampoline `.exe` for Shims\n\n## Status\n\nImplemented\n\n## Summary\n\nReplace Windows `.cmd` wrapper scripts with lightweight trampoline `.exe` binaries for all shim tools (`vp`, `node`, `npm`, `npx`, `vpx`, and globally installed package binaries). This eliminates the `Terminate batch job (Y/N)?` prompt that appears when users press Ctrl+C, providing the same clean signal behavior as direct `.exe` invocation.\n\n## Motivation\n\n### The Problem\n\nOn Windows, the vite-plus CLI previously exposed tools through `.cmd` batch file wrappers:\n\n```\n~/.vite-plus/bin/\n├── vp.cmd          → calls current\\bin\\vp.exe\n├── node.cmd        → calls vp.exe env exec node\n├── npm.cmd         → calls vp.exe env exec npm\n├── npx.cmd         → calls vp.exe env exec npx\n└── ...\n```\n\nWhen a user presses Ctrl+C while a command is running through a `.cmd` wrapper, `cmd.exe` intercepts the signal and displays:\n\n```\nTerminate batch job (Y/N)?\n```\n\nThis is a fundamental limitation of batch file execution on Windows. The prompt:\n\n- Interrupts the normal Ctrl+C workflow that users expect\n- May appear multiple times (once per `.cmd` in the chain)\n- Differs from Unix behavior where Ctrl+C cleanly terminates the process\n- Cannot be suppressed from within the batch file\n\n### Confirmed Behavior\n\nAs demonstrated in [issue #835](https://github.com/voidzero-dev/vite-plus/issues/835):\n\n1. Running `vp dev` (through `vp.cmd`) shows `Terminate batch job (Y/N)?` on Ctrl+C\n2. Running `~/.vite-plus/current/bin/vp.exe dev` directly does **NOT** show the prompt\n3. Running `npm.cmd run dev` shows the prompt; running `npm.ps1 run dev` does not\n4. The prompt can appear multiple times when `.cmd` wrappers chain (e.g., `vp.cmd` → `npm.cmd`)\n\n### Why `.ps1` Scripts Are Not Sufficient\n\nPowerShell `.ps1` scripts avoid the Ctrl+C issue but have critical limitations:\n\n- `where.exe` and `which` do not discover `.ps1` files as executables\n- Only work in PowerShell, not in `cmd.exe`, Git Bash, or other shells\n- Cannot serve as universal shims\n\n## Architecture\n\n### Unix (Symlink-Based — Unchanged)\n\nOn Unix, shims are symlinks to the `vp` binary. The binary detects the tool name from `argv[0]`:\n\n```\n~/.vite-plus/bin/\n├── vp   → ../current/bin/vp     (symlink)\n├── node → ../current/bin/vp     (symlink)\n├── npm  → ../current/bin/vp     (symlink)\n└── npx  → ../current/bin/vp     (symlink)\n```\n\n### Windows (Trampoline `.exe` Files)\n\n```\n~/.vite-plus/bin/\n├── vp.exe       # Trampoline → spawns current\\bin\\vp.exe\n├── node.exe     # Trampoline → sets VITE_PLUS_SHIM_TOOL=node, spawns vp.exe\n├── npm.exe      # Trampoline → sets VITE_PLUS_SHIM_TOOL=npm, spawns vp.exe\n├── npx.exe      # Trampoline → sets VITE_PLUS_SHIM_TOOL=npx, spawns vp.exe\n├── vpx.exe      # Trampoline → sets VITE_PLUS_SHIM_TOOL=vpx, spawns vp.exe\n└── tsc.exe      # Trampoline → sets VITE_PLUS_SHIM_TOOL=tsc, spawns vp.exe (package shim)\n```\n\nEach trampoline is a copy of `vp-shim.exe` (the template binary distributed alongside `vp.exe`).\n\n**Note**: npm-installed packages (via `npm install -g`) still use `.cmd` wrappers because they lack `PackageMetadata` and need to point directly at npm's generated scripts.\n\n## Implementation\n\n### Crate Structure\n\n```\ncrates/vite_trampoline/\n├── Cargo.toml      # Zero external dependencies\n├── src/\n│   └── main.rs     # ~90 lines, single-file binary\n```\n\n### Trampoline Binary\n\nThe trampoline has **zero external dependencies** — the Win32 FFI call (`SetConsoleCtrlHandler`) is declared inline to avoid the heavy `windows`/`windows-core` crates. It also avoids `core::fmt` (~100KB overhead) by never using `format!`, `eprintln!`, `println!`, or `.unwrap()`.\n\n```rust\nuse std::{env, process::{self, Command}};\n\nfn main() {\n    // 1. Determine tool name from own filename (e.g., node.exe → \"node\")\n    let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1));\n    let tool_name = exe_path.file_stem()\n        .and_then(|s| s.to_str())\n        .unwrap_or_else(|| process::exit(1));\n\n    // 2. Locate vp.exe at ../current/bin/vp.exe\n    let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1));\n    let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1));\n    let vp_exe = vp_home.join(\"current\").join(\"bin\").join(\"vp.exe\");\n\n    // 3. Install Ctrl+C handler (ignores signal; child handles it)\n    install_ctrl_handler();\n\n    // 4. Spawn vp.exe with env vars\n    let mut cmd = Command::new(&vp_exe);\n    cmd.args(env::args_os().skip(1));\n    cmd.env(\"VITE_PLUS_HOME\", vp_home);\n\n    if tool_name != \"vp\" {\n        cmd.env(\"VITE_PLUS_SHIM_TOOL\", tool_name);\n        cmd.env_remove(\"VITE_PLUS_TOOL_RECURSION\");\n    }\n\n    // 5. Propagate exit code (error message via write_all, not eprintln!)\n    match cmd.status() {\n        Ok(s) => process::exit(s.code().unwrap_or(1)),\n        Err(_) => {\n            use std::io::Write;\n            let mut stderr = std::io::stderr().lock();\n            let _ = stderr.write_all(b\"vite-plus: failed to execute \");\n            let _ = stderr.write_all(vp_exe.as_os_str().as_encoded_bytes());\n            let _ = stderr.write_all(b\"\\n\");\n            process::exit(1);\n        }\n    }\n}\n\nfn install_ctrl_handler() {\n    type HandlerRoutine = unsafe extern \"system\" fn(ctrl_type: u32) -> i32;\n    unsafe extern \"system\" {\n        fn SetConsoleCtrlHandler(handler: Option<HandlerRoutine>, add: i32) -> i32;\n    }\n    unsafe extern \"system\" fn handler(_ctrl_type: u32) -> i32 { 1 }\n    unsafe { SetConsoleCtrlHandler(Some(handler), 1); }\n}\n```\n\n### Size Optimization\n\n| Technique                                                                             | Savings                    | Status |\n| ------------------------------------------------------------------------------------- | -------------------------- | ------ |\n| Zero external dependencies (raw FFI)                                                  | ~20KB (vs `windows` crate) | Done   |\n| No direct `core::fmt` usage (avoid `eprintln!`/`format!`/`.unwrap()`)                 | Marginal                   | Done   |\n| Workspace profile: `lto=\"fat\"`, `codegen-units=1`, `strip=\"symbols\"`, `panic=\"abort\"` | Inherited                  | Done   |\n| Per-package `opt-level=\"z\"` (optimize for size)                                       | ~5-10%                     | Done   |\n\n**Binary size**: ~200KB on Windows. The floor is set by `std::process::Command` which internally pulls in `core::fmt` for error formatting regardless of whether our code uses it. Further reduction to ~40-50KB (matching uv-trampoline) would require replacing `Command` with raw `CreateProcessW` and using nightly Rust (see Future Optimizations).\n\n### Environment Variables\n\nThe trampoline sets three env vars before spawning `vp.exe`:\n\n| Variable                   | When                       | Purpose                                                                        |\n| -------------------------- | -------------------------- | ------------------------------------------------------------------------------ |\n| `VITE_PLUS_HOME`           | Always                     | Tells vp.exe the install directory (derived from `bin_dir.parent()`)           |\n| `VITE_PLUS_SHIM_TOOL`      | Tool shims only (not \"vp\") | Tells vp.exe to enter shim dispatch mode for the named tool                    |\n| `VITE_PLUS_TOOL_RECURSION` | Removed for tool shims     | Clears the recursion marker for fresh version resolution in nested invocations |\n\n### Ctrl+C Handling\n\nThe trampoline installs a console control handler that returns `TRUE` (1):\n\n1. When Ctrl+C is pressed, Windows sends `CTRL_C_EVENT` to **all processes** in the console group\n2. The trampoline's handler returns 1 (TRUE) → trampoline stays alive\n3. The child process (`vp.exe` → Node.js) receives the **same** event\n4. The child decides how to handle it (typically exits gracefully)\n5. The trampoline detects the child's exit and propagates its exit code\n\n**No \"Terminate batch job?\" prompt** because there is no batch file involved.\n\n### Integration with Shim Detection\n\n`detect_shim_tool()` in `shim/mod.rs` checks `VITE_PLUS_SHIM_TOOL` env var **before** `argv[0]`:\n\n```\nTrampoline (node.exe)\n  → sets VITE_PLUS_SHIM_TOOL=node, VITE_PLUS_HOME=..., removes VITE_PLUS_TOOL_RECURSION\n  → spawns current/bin/vp.exe with original args\n    → detect_shim_tool() reads env var → \"node\"\n    → dispatch(\"node\", args)\n    → resolves Node.js version, executes real node\n```\n\n### Running Exe Overwrite\n\nWhen `vp env setup --refresh` is invoked through the trampoline (`~/.vite-plus/bin/vp.exe`), the trampoline is still running. Windows prevents overwriting a running `.exe`. The solution:\n\n1. Rename existing `vp.exe` to `vp.exe.<unix_timestamp>.old`\n2. Copy new trampoline to `vp.exe`\n3. Best-effort cleanup of all `*.old` files in the bin directory\n\n### Distribution\n\nThe trampoline binary (`vp-shim.exe`) is distributed alongside `vp.exe`:\n\n```\n~/.vite-plus/current/bin/\n├── vp.exe          # Main CLI binary\n└── vp-shim.exe     # Trampoline template (copied as shims)\n```\n\nIncluded in:\n\n- Platform npm packages (`@voidzero-dev/vite-plus-cli-win32-x64-msvc`)\n- Release artifacts (`.github/workflows/release.yml`)\n- `install.ps1` and `install.sh` (both local dev and download paths)\n- `extract_platform_package()` in the upgrade path\n\n### Legacy Fallback\n\nWhen installing a pre-trampoline version (no `vp-shim.exe` in the package):\n\n- `install.ps1` falls back to creating `.cmd` + shell script wrappers\n- Stale trampoline `.exe` shims from a newer install are removed (`.exe` takes precedence over `.cmd` on Windows PATH)\n\n## Comparison with uv-trampoline\n\n| Aspect              | uv-trampoline                            | vite-plus trampoline                 |\n| ------------------- | ---------------------------------------- | ------------------------------------ |\n| **Purpose**         | Launch Python with embedded script       | Forward to `vp.exe`                  |\n| **Complexity**      | High (PE resources, zipimport)           | Low (filename + spawn)               |\n| **Data embedding**  | PE resources (kind, path, script ZIP)    | None (uses filename + relative path) |\n| **Dependencies**    | `windows` crate (unsafe, no CRT)         | Zero (raw FFI declaration)           |\n| **Toolchain**       | Nightly Rust (`panic=\"immediate-abort\"`) | Stable Rust                          |\n| **Binary size**     | 39-47 KB                                 | ~200 KB                              |\n| **Entry point**     | `#![no_main]` + `mainCRTStartup`         | Standard `fn main()`                 |\n| **Error output**    | `ufmt` (no `core::fmt`)                  | `write_all` (no `core::fmt`)         |\n| **Ctrl+C handling** | `SetConsoleCtrlHandler` → ignore         | Same approach                        |\n| **Exit code**       | `GetExitCodeProcess` → `exit()`          | `Command::status()` → `exit()`       |\n\nThe vite-plus trampoline is significantly simpler because it doesn't need to embed data in PE resources — it just reads its own filename, finds `vp.exe` at a fixed relative path, and spawns it. The ~150KB size difference from uv-trampoline comes from `std::process::Command` (which internally pulls in `core::fmt`) versus raw `CreateProcessW` with nightly-only `#![no_main]`.\n\n## Alternatives Considered\n\n### 1. NTFS Hardlinks (Rejected)\n\nHardlinks resolve to physical file inodes, not through directory junctions. After `vp` upgrade re-points `current`, hardlinks in `bin/` still reference the old binary.\n\n### 2. Windows Symbolic Links (Rejected)\n\nRequires administrator privileges or Developer Mode. Not reliable for all users.\n\n### 3. PowerShell `.ps1` Scripts (Rejected)\n\n`where.exe` and `which` do not find `.ps1` files. Only works in PowerShell.\n\n### 4. Copy `vp.exe` as Each Shim (Rejected)\n\n~5-10MB per copy. Trampoline achieves the same result at ~100KB.\n\n### 5. `windows` Crate for FFI (Rejected)\n\nAdds ~100KB to the binary for a single `SetConsoleCtrlHandler` call. Raw FFI declaration is sufficient.\n\n## Future Optimizations\n\nIf the ~100KB binary size needs to be reduced further:\n\n1. **Switch to nightly Rust** with `panic=\"immediate-abort\"` and `#![no_main]` + `mainCRTStartup` (~50KB savings)\n2. **Use raw Win32 `CreateProcessW`** instead of `std::process::Command` (eliminates most of std's process machinery)\n3. **Pre-build and check in** trampoline binaries (like uv does) to decouple the trampoline build from the workspace toolchain\n\nThese would bring the binary to ~40-50KB, matching uv-trampoline, at the cost of requiring a nightly toolchain and more unsafe code.\n\n## References\n\n- [Issue #835](https://github.com/voidzero-dev/vite-plus/issues/835): Original feature request with video reproduction\n- [uv-trampoline](https://github.com/astral-sh/uv/tree/main/crates/uv-trampoline): Reference implementation by astral-sh (~40KB with nightly Rust)\n- [RFC: env-command](./env-command.md): Shim architecture documentation\n- [RFC: upgrade-command](./upgrade-command.md): Upgrade/rollback flow\n"
  },
  {
    "path": "rfcs/update-package-command.md",
    "content": "# RFC: Vite+ Update Package Command\n\n## Summary\n\nAdd `vp update` (alias: `vp up`) command that automatically adapts to the detected package manager (pnpm/yarn/npm) for updating packages to their latest versions within the specified semver range, with support for updating to absolute latest versions, workspace-aware operations, and interactive mode.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands to update dependencies:\n\n```bash\npnpm update react\nyarn upgrade react\nnpm update react\n```\n\nThis creates friction in monorepo workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify workflows**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm update react --latest          # pnpm project\nyarn upgrade react --latest         # yarn project\nnpm update react                    # npm project (no --latest flag)\n\n# Different commands for updating all packages\npnpm update                         # pnpm\nyarn upgrade                        # yarn@1 / yarn upgrade-interactive for yarn@2+\nnpm update                          # npm\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp update react             # Update to latest within semver range\nvp up react --latest        # Update to absolute latest version\nvp update                   # Update all packages\n\n# Workspace operations\nvp update --filter app                    # Update in specific package\nvp update react --latest --filter \"app*\"  # Update to latest in multiple packages\nvp update -r                              # Update recursively in all workspaces\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n#### Update Command\n\n```bash\nvp update [PACKAGES]... [OPTIONS]\nvp up [PACKAGES]... [OPTIONS]        # Alias\n```\n\n**Examples:**\n\n```bash\n# Update to latest version within semver range\nvp update react react-dom\n\n# Update to absolute latest version\nvp update react --latest\nvp up react -L\n\n# Update all dependencies\nvp update\n\n# Update all to latest\nvp update --latest\n\n# Update only dev dependencies\nvp update -D\n\n# Update only production dependencies\nvp update -P\n\n# Workspace operations\nvp update --filter app                    # Update in specific package\nvp update react --latest --filter \"app*\"  # Update in multiple packages\nvp update -r                              # Update in all workspace packages\nvp update -g typescript                   # Update global package\n\n# Interactive mode (pnpm only)\nvp update --interactive\nvp up -i\n\n# Advanced options\nvp update --no-optional                   # Skip optional dependencies\nvp update --no-save                       # Update lockfile only\nvp update react --latest --no-save        # Test latest version without saving\n```\n\n### Command Mapping\n\n#### Update Command Mapping\n\n- https://pnpm.io/cli/update\n- https://yarnpkg.com/cli/up (yarn@2+)\n- https://classic.yarnpkg.com/en/docs/cli/upgrade (yarn@1)\n- https://docs.npmjs.com/cli/v11/commands/npm-update\n\n| Vite+ Flag             | pnpm                        | yarn@1               | yarn@2+                                     | npm                            | Description                                                |\n| ---------------------- | --------------------------- | -------------------- | ------------------------------------------- | ------------------------------ | ---------------------------------------------------------- |\n| `[packages]`           | `update [packages]`         | `upgrade [packages]` | `up [packages]`                             | `update [packages]`            | Update specific packages (or all if omitted)               |\n| `-L, --latest`         | `--latest` / `-L`           | `--latest`           | N/A (default behavior)                      | N/A                            | Update to latest version (ignore semver range)             |\n| `-g, --global`         | N/A                         | N/A                  | N/A                                         | `--global` / `-g`              | Update global packages                                     |\n| `-r, --recursive`      | `-r, --recursive`           | N/A                  | `--recursive` / `-R`                        | `--workspaces`                 | Update recursively in all workspace packages               |\n| `--filter <pattern>`   | `--filter <pattern> update` | N/A                  | `workspaces foreach --include <pattern> up` | `update --workspace <pattern>` | Target specific workspace package(s)                       |\n| `-w, --workspace-root` | `-w`                        | N/A                  | N/A                                         | `--include-workspace-root`     | Include workspace root                                     |\n| `-D, --dev`            | `--dev` / `-D`              | N/A                  | N/A                                         | `--include=dev`                | Update only devDependencies                                |\n| `-P, --prod`           | `--prod` / `-P`             | N/A                  | N/A                                         | `--include=prod`               | Update only dependencies and optionalDependencies          |\n| `-i, --interactive`    | `--interactive` / `-i`      | N/A                  | `--interactive` / `-i`                      | N/A                            | Show outdated packages and choose which to update          |\n| `--no-optional`        | `--no-optional`             | N/A                  | N/A                                         | `--no-optional`                | Don't update optionalDependencies                          |\n| `--no-save`            | `--no-save`                 | N/A                  | N/A                                         | `--no-save`                    | Update lockfile only, don't modify package.json            |\n| `--workspace`          | `--workspace`               | N/A                  | N/A                                         | N/A                            | Only update if package exists in workspace (pnpm-specific) |\n\n**Note**:\n\n- For pnpm, `--filter` must come before the command (e.g., `pnpm --filter app update react`)\n- Yarn@2+ uses `up` or `upgrade` command, and updates to latest by default\n- Yarn@1 uses `upgrade` command\n- npm doesn't support `--latest` flag, it always updates within semver range\n- `--no-optional` skips updating optional dependencies (pnpm/npm only)\n- `--no-save` updates lockfile without modifying package.json (pnpm/npm only)\n\n**Aliases:**\n\n- `vp up` = `vp update`\n\n### Command Translation Strategy\n\n#### Global Package Updates\n\nFor global packages, use npm cli only (same as add/remove):\n\n```bash\nvp update -g typescript\n-> npm update --global typescript\n```\n\n#### Latest Version Updates\n\nDifferent package managers handle \"latest\" differently:\n\n**pnpm**: Has explicit `--latest` flag\n\n```bash\nvp update react --latest\n-> pnpm update --latest react\n```\n\n**yarn@1**: Has `--latest` flag\n\n```bash\nvp update react --latest\n-> yarn upgrade --latest react\n```\n\n**yarn@2+**: Updates to latest by default, use `^` or `~` for range updates\n\n```bash\nvp update react --latest\n-> yarn up react                    # Already updates to latest\n```\n\n**npm**: No `--latest` flag, only updates within semver range\n\n```bash\nvp update react --latest\n-> npx npm-check-updates -u react && npm install\n# OR warn user and update within range\n-> npm update react\n```\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command variant:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Update packages to their latest versions\n    #[command(alias = \"up\")]\n    Update {\n        /// Update to latest version (ignore semver range)\n        #[arg(short = 'L', long)]\n        latest: bool,\n\n        /// Update global packages\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Update recursively in all workspace packages\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo (can be used multiple times)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Option<Vec<String>>,\n\n        /// Include workspace root\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Update only devDependencies\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Update only dependencies (production)\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Interactive mode - show outdated packages and choose\n        #[arg(short = 'i', long)]\n        interactive: bool,\n\n        /// Don't update optionalDependencies\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Update lockfile only, don't modify package.json\n        #[arg(long)]\n        no_save: bool,\n\n        /// Packages to update (optional - updates all if omitted)\n        packages: Vec<String>,\n\n        /// Additional arguments to pass through to the package manager\n        #[arg(last = true, allow_hyphen_values = true)]\n        pass_through_args: Option<Vec<String>>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/update.rs` (new file)\n\n```rust\n#[derive(Debug, Default)]\npub struct UpdateCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub latest: bool,\n    pub global: bool,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub dev: bool,\n    pub prod: bool,\n    pub interactive: bool,\n    pub no_optional: bool,\n    pub no_save: bool,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    pub fn resolve_update_command(&self, options: &UpdateCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let mut args: Vec<String> = Vec::new();\n\n        // Global packages use npm only\n        if options.global {\n            bin_name = \"npm\".into();\n            args.push(\"update\".into());\n            args.push(\"--global\".into());\n            args.extend_from_slice(options.packages);\n            return ResolveCommandResult { bin_path: bin_name, args, envs };\n        }\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                args.push(\"update\".into());\n\n                if options.latest {\n                    args.push(\"--latest\".into());\n                }\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n                if options.interactive {\n                    args.push(\"--interactive\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n                if options.no_save {\n                    args.push(\"--no-save\".into());\n                }\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n\n                // Determine yarn version\n                let is_yarn_v1 = self.version.starts_with(\"1.\");\n\n                if is_yarn_v1 {\n                    // yarn@1: yarn upgrade [--latest]\n                    if let Some(filters) = options.filters {\n                        // yarn@1 doesn't support workspace filtering well\n                        // Use basic workspace command\n                        args.push(\"workspace\".into());\n                        args.push(filters[0].clone());\n                    }\n                    args.push(\"upgrade\".into());\n                    if options.latest {\n                        args.push(\"--latest\".into());\n                    }\n                } else {\n                    // yarn@2+: yarn up (already updates to latest by default)\n                    if let Some(filters) = options.filters {\n                        args.push(\"workspaces\".into());\n                        args.push(\"foreach\".into());\n                        args.push(\"--all\".into());\n                        for filter in filters {\n                            args.push(\"--include\".into());\n                            args.push(filter.clone());\n                        }\n                    }\n                    args.push(\"up\".into());\n                    if options.recursive {\n                        args.push(\"--recursive\".into());\n                    }\n                    if options.interactive {\n                        args.push(\"--interactive\".into());\n                    }\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n                args.push(\"update\".into());\n\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n                if options.workspace_root {\n                    args.push(\"--include-workspace-root\".into());\n                }\n                if options.recursive {\n                    args.push(\"--workspaces\".into());\n                }\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n                if options.no_save {\n                    args.push(\"--no-save\".into());\n                }\n\n                // npm doesn't have --latest flag\n                // Warn user or handle differently\n                if options.latest {\n                    eprintln!(\"Warning: npm doesn't support --latest flag. Use 'npm outdated' to check for updates.\");\n                }\n            }\n        }\n\n        args.extend_from_slice(options.packages);\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n```\n\n#### 3. Update Command Implementation\n\n**File**: `crates/vite_task/src/update.rs` (new file)\n\n```rust\npub struct UpdateCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl UpdateCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        packages: &[String],\n        latest: bool,\n        global: bool,\n        recursive: bool,\n        filters: Option<&[String]>,\n        workspace_root: bool,\n        dev: bool,\n        prod: bool,\n        interactive: bool,\n        no_optional: bool,\n        no_save: bool,\n        pass_through_args: Option<&[String]>,\n    ) -> Result<ExecutionSummary, Error> {\n        // Detect package manager\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        let update_command_options = UpdateCommandOptions {\n            packages,\n            latest,\n            global,\n            recursive,\n            filters,\n            workspace_root,\n            dev,\n            prod,\n            interactive,\n            no_optional,\n            no_save,\n            pass_through_args,\n        };\n        let resolve_command = package_manager.resolve_update_command(&update_command_options);\n\n        println!(\"Running: {} {}\", resolve_command.bin_path, resolve_command.args.join(\" \"));\n\n        let resolved_task = ResolvedTask::resolve_from_builtin_with_command_result(\n            &workspace,\n            \"update\",\n            resolve_command.args.iter(),\n            ResolveCommandResult { bin_path: resolve_command.bin_path, envs: resolve_command.envs },\n            false,\n            None,\n        )?;\n\n        let mut task_graph: StableGraph<ResolvedTask, ()> = Default::default();\n        task_graph.add_node(resolved_task);\n        let summary = ExecutionPlan::plan(task_graph, false)?.execute(&workspace).await?;\n        workspace.unload().await?;\n\n        Ok(summary)\n    }\n}\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache update operations.\n\n**Rationale**:\n\n- Update commands modify package.json and lockfiles\n- Side effects make caching inappropriate\n- Each execution should run fresh\n- Similar to how add/remove/install work\n\n### 2. Default Behavior: Update All vs Specific\n\n**Decision**: When no packages are specified, update all dependencies.\n\n**Rationale**:\n\n- Matches behavior of all three package managers\n- Common use case: `vp update` to update everything\n- Specific updates: `vp update react`\n\n### 3. Latest Flag Handling for npm\n\n**Decision**: Warn users that npm doesn't support --latest, but still run the command.\n\n**Rationale**:\n\n- npm only updates within semver range\n- Alternative tools like `npm-check-updates` exist but require separate installation\n- Better to warn and proceed than to fail\n\n**Alternative**: Could integrate with `npx npm-check-updates`:\n\n```bash\nvp update react --latest\n# For npm: npx npm-check-updates -u react && npm install\n```\n\n### 4. Interactive Mode\n\n**Decision**: Support interactive mode for pnpm and yarn@2+.\n\n**Rationale**:\n\n- pnpm has `--interactive` flag\n- yarn@2+ has `--interactive` flag\n- Provides better UX for reviewing updates\n- npm doesn't support this natively\n\n### 5. Workspace Filtering\n\n**Decision**: Use same filtering approach as add/remove commands.\n\n**Rationale**:\n\n- Consistency across commands\n- Leverage existing filter patterns\n- Works well with pnpm's filter syntax\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vp update react\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### Interactive Mode Not Supported\n\n```bash\n$ vp update --interactive\nWarning: npm doesn't support interactive mode\nRunning standard update instead...\n```\n\n## User Experience\n\n### Success Output\n\n```bash\n$ vp update react --latest\nDetected package manager: pnpm@10.15.0\nRunning: pnpm update --latest react\n\nPackages: +0 -0 ~1\n~1\nProgress: resolved 150, reused 145, downloaded 1, added 0, done\n\ndependencies:\n~ react 18.2.0 → 18.3.1\n\nDone in 1.2s\n```\n\n### Interactive Mode Output\n\n```bash\n$ vp up -i\nDetected package manager: pnpm@10.15.0\nRunning: pnpm update --interactive\n\n? Choose which packages to update: (Press <space> to select, <a> to select all)\n❯◯ react 18.2.0 → 18.3.1\n ◯ react-dom 18.2.0 → 18.3.1\n ◯ typescript 5.0.0 → 5.5.0\n ◯ vite 5.0.0 → 6.0.0\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Separate Command for Latest Updates\n\n```bash\nvp update react        # Update within range\nvp upgrade react       # Update to latest\n```\n\n**Rejected because**:\n\n- More commands to remember\n- `--latest` flag is clearer\n- Matches pnpm's API design\n\n### Alternative 2: Always Update to Latest\n\n```bash\nvp update react        # Always updates to latest\nvp update react --range # Updates within semver range\n```\n\n**Rejected because**:\n\n- Breaks semver expectations\n- Different from package manager defaults\n- Could cause unexpected breaking changes\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Update` command variant to `Commands` enum\n2. Create `update.rs` module in both crates\n3. Implement package manager command resolution\n4. Add basic error handling\n\n### Phase 2: Advanced Features\n\n1. Add interactive mode support\n2. Implement workspace filtering\n3. Add dev/prod dependency filtering\n4. Handle yarn version detection\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Integration tests with mock package managers\n3. Test interactive mode (where supported)\n4. Test workspace operations\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document package manager compatibility\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x [WIP]\n- pnpm@10.x\n- yarn@1.x [WIP]\n- yarn@4.x\n- npm@10.x\n- npm@11.x [WIP]\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_update_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_update_command(&UpdateCommandOptions {\n        packages: &[\"react\".to_string()],\n        latest: false,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"update\", \"react\"]);\n}\n\n#[test]\nfn test_pnpm_update_latest() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_update_command(&UpdateCommandOptions {\n        packages: &[\"react\".to_string()],\n        latest: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"update\", \"--latest\", \"react\"]);\n}\n\n#[test]\nfn test_npm_update_latest_warning() {\n    // Should warn but still execute\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_update_command(&UpdateCommandOptions {\n        packages: &[\"react\".to_string()],\n        latest: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"update\", \"react\"]);\n}\n```\n\n## CLI Help Output\n\n```bash\n$ vp update --help\nUpdate packages to their latest versions\n\nUsage: vp update [PACKAGES]... [OPTIONS]\n\nAliases: up\n\nArguments:\n  [PACKAGES]...  Packages to update (updates all if omitted)\n\nOptions:\n  -L, --latest           Update to latest version (ignore semver range)\n  -g, --global           Update global packages\n  -r, --recursive        Update recursively in all workspace packages\n  --filter <PATTERN>     Filter packages in monorepo (can be used multiple times)\n  -w, --workspace-root   Include workspace root\n  -D, --dev              Update only devDependencies\n  -P, --prod             Update only dependencies\n  -i, --interactive      Show outdated packages and choose which to update\n  --no-optional          Don't update optionalDependencies\n  --no-save              Update lockfile only, don't modify package.json\n  -h, --help             Print help\n\nExamples:\n  vp update                          # Update all packages within semver range\n  vp update react react-dom          # Update specific packages\n  vp update --latest                 # Update all to latest versions\n  vp up react -L                     # Update react to latest\n  vp update -i                       # Interactive mode\n  vp update --filter app             # Update in specific workspace\n  vp update -r                       # Update in all workspaces\n  vp update -D                       # Update only dev dependencies\n  vp update --no-optional            # Skip optional dependencies\n  vp update --no-save                # Update lockfile only\n```\n\n## Real-World Usage Examples\n\n### Monorepo Package Updates\n\n```bash\n# Update React in all frontend packages to latest\nvp update react react-dom --latest --filter \"@myorg/app-*\"\n\n# Update all dev dependencies in all packages\nvp update -D -r\n\n# Interactive update in specific package\nvp update -i --filter web\n\n# Update all to latest in workspace root\nvp update --latest -w\n\n# Update TypeScript across entire monorepo\nvp update typescript --latest -r\n```\n\n### Development Workflow\n\n```bash\n# Check for updates interactively\nvp up -i\n\n# Update all dependencies within semver range\nvp update\n\n# Update security patches\nvp update\n\n# Update to latest versions (major updates)\nvp update --latest\n\n# Update specific package to latest\nvp up react -L\n\n# Update global packages\nvp update -g typescript\n\n# Test updates without saving to package.json\nvp update --no-save\n\n# Update without optional dependencies\nvp update --no-optional\n```\n\n## Package Manager Compatibility\n\n| Feature          | pnpm               | yarn@1           | yarn@2+          | npm              | Notes                      |\n| ---------------- | ------------------ | ---------------- | ---------------- | ---------------- | -------------------------- |\n| Update command   | `update`           | `upgrade`        | `up`             | `update`         | Different command names    |\n| Latest flag      | `--latest` / `-L`  | `--latest`       | N/A (default)    | ❌ Not supported | npm only updates in range  |\n| Interactive      | `--interactive`    | ❌ Not supported | `--interactive`  | ❌ Not supported | Limited support            |\n| Workspace filter | `--filter`         | ⚠️ Limited       | ⚠️ Limited       | `--workspace`    | pnpm most flexible         |\n| Recursive        | `--recursive`      | ❌ Not supported | `--recursive`    | `--workspaces`   | Different flags            |\n| Dev/Prod filter  | `--dev` / `--prod` | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only                  |\n| Global           | `-g`               | `global upgrade` | ❌ Not supported | `-g`             | Use npm for global         |\n| No optional      | `--no-optional`    | ❌ Not supported | ❌ Not supported | `--no-optional`  | Skip optional dependencies |\n| No save          | `--no-save`        | ❌ Not supported | ❌ Not supported | `--no-save`      | Lockfile only updates      |\n\n## Future Enhancements\n\n### 1. Outdated Command\n\nShow outdated packages before updating:\n\n```bash\nvp outdated\nvp outdated --filter app\n```\n\n### 2. Smart Update Suggestions\n\n```bash\n$ vp update\nAnalyzing dependencies...\n⚠️  Major updates available:\n  react 17.0.0 → 18.3.1 (breaking changes)\n\n✓ Minor updates:\n  lodash 4.17.20 → 4.17.21\n\nRun 'vp update --latest' to update to latest versions\nRun 'vp update -i' for interactive mode\n```\n\n### 3. Changelog Display\n\n```bash\n$ vp update react --latest\nUpdating react 18.2.0 → 18.3.1\n\n📝 Changelog:\n  - New useOptimistic hook\n  - Performance improvements\n  - Bug fixes\n\nContinue? (Y/n)\n```\n\n## Success Metrics\n\n1. **Adoption**: % of users using `vp update` vs direct package manager\n2. **Update Frequency**: Track how often dependencies are kept up-to-date\n3. **User Feedback**: Survey/issues about command ergonomics\n4. **Error Rate**: Track command failures vs package manager direct usage\n\n## Conclusion\n\nThis RFC proposes adding `vp update` command to provide a unified interface for updating packages across pnpm/yarn/npm. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports updating specific packages or all packages\n- ✅ Provides `--latest` flag to update beyond semver range\n- ✅ Full workspace support with filtering\n- ✅ Interactive mode for better UX (where supported)\n- ✅ Graceful degradation for package manager-specific features\n- ✅ No caching overhead\n- ✅ Simple implementation leveraging existing infrastructure\n\nThe implementation follows the same patterns as add/remove commands while providing the update-specific features developers need.\n"
  },
  {
    "path": "rfcs/upgrade-command.md",
    "content": "# RFC: Self-Update Command\n\n## Status\n\nDraft\n\n## Background\n\nVite+ is distributed as a standalone Rust binary via bash installation (`curl -fsSL https://vite.plus | bash`). Currently, users must re-run the full install script to update to a new version. This is friction-heavy and unfamiliar to users who expect a built-in update mechanism (like `rustup update`, `volta fetch`, or `brew upgrade`).\n\nA native `vp upgrade` command would allow users to update the CLI in-place with a single command, improving the upgrade experience significantly.\n\n### Current Installation Structure\n\n```\n~/.vite-plus/\n├── bin/\n│   ├── vp → ../current/bin/vp       # Stable symlink (in PATH)\n│   ├── node → ../current/bin/node   # Shim symlinks\n│   ├── npm → ../current/bin/npm\n│   └── npx → ../current/bin/npx\n├── current → 0.1.0/                 # Symlink to active version\n├── 0.1.0/                           # Version directory\n│   ├── bin/vp                       # Actual binary\n│   ├── dist/                        # JS bundles + .node files\n│   ├── package.json\n│   └── node_modules/\n├── 0.0.9/                           # Previous version (kept for rollback)\n├── env                              # POSIX shell env (sourced by shell config)\n├── env.fish                         # Fish shell env\n└── env.ps1                          # PowerShell env\n```\n\nKey invariant: `~/.vite-plus/bin/vp` is a symlink to `../current/bin/vp` (Unix) or a trampoline `.exe` forwarding to `current\\bin\\vp.exe` (Windows), and `current` is a symlink (Unix) or junction (Windows) to the active version directory. Upgrading swaps the `current` link — atomic on Unix, near-instant on Windows.\n\n## Goals\n\n1. Provide a fast, reliable `vp upgrade` command that upgrades the CLI to the latest (or specified) version\n2. Reuse the same npm-based distribution channel (no new infrastructure)\n3. Support atomic upgrades with automatic rollback on failure\n4. Keep the last 5 versions for manual rollback\n5. Support version pinning and channel selection (latest, test)\n\n## Non-Goals\n\n1. Auto-update on every command invocation (may be a future enhancement)\n2. Windows PowerShell install path (covered by `install.ps1`)\n3. Migrating away from npm as the distribution channel\n4. Updating Node.js versions (already handled by `vp env`)\n\n## User Stories\n\n### Story 1: Quick Update to Latest\n\nA developer sees that a new version of Vite+ is available and wants to update.\n\n```bash\n$ vp upgrade\ninfo: checking for updates...\ninfo: found vite-plus-cli@0.2.0 (current: 0.1.0)\ninfo: downloading vite-plus-cli@0.2.0 for darwin-arm64...\ninfo: installing...\n\n✔ Updated vite-plus from 0.1.0 → 0.2.0\n\n  Release notes: https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.0\n```\n\n### Story 2: Already Up to Date\n\n```bash\n$ vp upgrade\ninfo: checking for updates...\n\n✔ Already up to date (0.2.0)\n```\n\n### Story 3: Update to a Specific Version\n\n```bash\n$ vp upgrade 0.1.5\ninfo: checking for updates...\ninfo: found vite-plus-cli@0.1.5 (current: 0.2.0)\ninfo: downloading vite-plus-cli@0.1.5 for darwin-arm64...\ninfo: installing...\n\n✔ Updated vite-plus from 0.2.0 → 0.1.5\n```\n\n### Story 4: Install a Test Channel Build\n\n```bash\n$ vp upgrade --tag test\ninfo: checking for updates...\ninfo: found vite-plus-cli@0.3.0-beta.1 (current: 0.2.0)\ninfo: downloading vite-plus-cli@0.3.0-beta.1 for darwin-arm64...\ninfo: installing...\n\n✔ Updated vite-plus from 0.2.0 → 0.3.0-beta.1\n```\n\n### Story 5: Rollback to Previous Version\n\n```bash\n$ vp upgrade --rollback\ninfo: rolling back to previous version...\ninfo: switching from 0.2.0 → 0.1.0\n\n✔ Rolled back to 0.1.0\n```\n\n### Story 6: Check for Updates Without Installing\n\n```bash\n$ vp upgrade --check\ninfo: checking for updates...\nUpdate available: 0.2.0 → 0.3.0\nRun `vp upgrade` to update.\n```\n\n### Story 7: CI Environment — Non-interactive\n\n```bash\n# In CI, just update silently\n$ vp upgrade --silent\n```\n\n## Technical Design\n\n### Command Interface\n\n```\nvp upgrade [VERSION] [OPTIONS]\nvp upgrade [VERSION] [OPTIONS]       # alias\n\nArguments:\n  [VERSION]    Target version (e.g., \"0.2.0\"). Defaults to \"latest\"\n\nOptions:\n  --tag <TAG>      npm dist-tag to install (default: \"latest\", also: \"test\")\n  --check          Check for updates without installing\n  --rollback       Revert to the previously active version\n  --force          Force reinstall even if already on the target version\n  --silent         Suppress output (useful in CI)\n  --registry <URL> Custom npm registry URL (overrides NPM_CONFIG_REGISTRY)\n```\n\n### Architecture\n\nThe upgrade command is implemented entirely in Rust within the `vite_global_cli` crate, mirroring the logic of `install.sh` but running as a native subprocess workflow.\n\n```\n┌─────────────────────────────────────────────────┐\n│                vp upgrade                   │\n├─────────────────────────────────────────────────┤\n│  1. Resolve version (npm registry query)        │\n│  2. Check if already installed                  │\n│  3. Download platform binary (.tgz)             │\n│  4. Download main JS bundle (.tgz)              │\n│  5. Extract to ~/.vite-plus/{version}/          │\n│  6. Install production dependencies             │\n│  7. Atomic swap: current → {version}            │\n│  8. Refresh shims (non-fatal)                   │\n│  9. Cleanup old versions (non-fatal, keep 5)    │\n└─────────────────────────────────────────────────┘\n```\n\n### Implementation Flow\n\n#### Step 1: Version Resolution\n\nQuery the npm registry for the target version:\n\n```\nGET {registry}/vite-plus-cli/{version_or_tag}\n```\n\n- If `VERSION` arg is provided, use it directly\n- If `--tag` is provided, resolve that dist-tag (e.g., `latest`, `test`)\n- Default to `latest`\n\nParse the JSON response to extract:\n\n- `version`: the resolved semver version\n- `optionalDependencies`: to find the platform-specific package name\n\n#### Step 2: Version Comparison\n\nCompare the resolved version against the currently running binary's version (`env!(\"CARGO_PKG_VERSION\")`).\n\n- If same version and `--force` is not set: print \"already up to date\" and exit\n- If target is older: proceed (allows deliberate downgrade)\n\n#### Step 3: Download and Verify\n\nDownload two tarballs from the npm registry:\n\n1. **Platform binary**: `{registry}/@voidzero-dev/vite-plus-cli-{platform_suffix}/-/vite-plus-cli-{suffix}-{version}.tgz`\n   - Contains: `vp` binary + `.node` NAPI files\n2. **Main package**: `{registry}/vite-plus-cli/-/vite-plus-cli-{version}.tgz`\n   - Contains: `dist/` (JS bundles), `package.json`, `templates/`, `rules/`, `AGENTS.md`\n\n**Integrity verification**: Each tarball is verified against the `integrity` field from the npm registry metadata. The npm registry provides SHA-512 hashes in the [Subresource Integrity](https://w3c.github.io/webappsec-subresource-integrity/) format:\n\n```json\n{\n  \"dist\": {\n    \"tarball\": \"https://registry.npmjs.org/vite-plus-cli/-/vite-plus-cli-0.0.0-xxx.tgz\",\n    \"integrity\": \"sha512-Z3se9k/NTRf8s5eSmuSoMOFFB/TUGBHIoeWDU5VoHV...\",\n    \"shasum\": \"3399579218148ae410011bde8934e12209743ef3\"\n  }\n}\n```\n\nVerification flow:\n\n1. Download tarball to temp file\n2. Compute SHA-512 hash of the downloaded file\n3. Base64-encode and compare against `integrity` field (format: `sha512-{base64}`)\n4. If mismatch: delete temp file, report error, abort update\n\n```rust\nuse sha2::{Sha512, Digest};\nuse base64::{Engine as _, engine::general_purpose::STANDARD};\n\nfn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> {\n    // Parse \"sha512-{base64}\" format\n    let expected_hash = expected.strip_prefix(\"sha512-\")\n        .ok_or(Error::UnsupportedIntegrity(expected.into()))?;\n\n    let mut hasher = Sha512::new();\n    hasher.update(data);\n    let actual_hash = STANDARD.encode(hasher.finalize());\n\n    if actual_hash != expected_hash {\n        return Err(Error::IntegrityMismatch {\n            expected: expected.into(),\n            actual: format!(\"sha512-{}\", actual_hash),\n        });\n    }\n    Ok(())\n}\n```\n\nTo get the `integrity` field for the platform package, we need to query its metadata separately:\n\n- Main package metadata: `{registry}/vite-plus-cli/{version}` → contains `dist.integrity`\n- Platform package metadata: `{registry}/@voidzero-dev/vite-plus-cli-{suffix}/{version}` → contains `dist.integrity`\n\nPlatform detection reuses existing logic from `vite_js_runtime` or mirrors the bash script's approach:\n\n- `uname -s` → os (darwin, linux)\n- `uname -m` → arch (x64, arm64)\n- Linux: detect gnu vs musl libc\n\n#### Step 4: Extract and Install\n\n1. Create `~/.vite-plus/{version}/` with `bin/` and `dist/` subdirectories\n2. Extract platform binary to `{version}/bin/vp`, set executable permissions\n3. Extract `.node` files to `{version}/dist/`\n4. Extract JS bundle, templates, rules, package.json to `{version}/`\n5. Strip `devDependencies` and `optionalDependencies` from package.json\n6. Run `vp install --silent` in the version directory to install production dependencies\n\n#### Step 5: Version Swap\n\n**Unix (macOS/Linux)** — Atomic symlink swap:\n\n```rust\n// Atomic symlink swap using rename\nlet temp_link = install_dir.join(\"current.new\");\nstd::os::unix::fs::symlink(version, &temp_link)?;\nstd::fs::rename(&temp_link, install_dir.join(\"current\"))?;\n```\n\nThis is atomic on POSIX systems because `rename()` on a symlink is an atomic operation.\n\n**Windows** — Junction swap (non-atomic, matching `install.ps1`):\n\n```rust\n// Windows uses junctions (mklink /J) — no admin privileges required\nlet current_link = install_dir.join(\"current\");\n\n// Remove existing junction\nif current_link.exists() {\n    junction::delete(&current_link)?;\n}\n\n// Create new junction pointing to version directory\njunction::create(version_dir, &current_link)?;\n```\n\nKey differences on Windows:\n\n- **Junctions** (`mklink /J`) are used instead of symlinks — junctions don't require admin privileges\n- Junctions only work for directories (which `current` is), and use absolute paths internally\n- The swap is **not atomic** — there's a brief window (~milliseconds) where `current` doesn't exist\n- `bin/vp.exe` is a trampoline (not a symlink) that resolves through `current`, so it doesn't need updating during upgrade\n- This matches the existing `install.ps1` behavior exactly\n\n#### Step 6: Post-Update (Non-Fatal)\n\nAfter the symlink swap (the **point of no return**), post-update operations are treated as non-fatal. Errors are printed to stderr as warnings but do not trigger the outer error handler (which would delete the now-active version directory).\n\n1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version. If this fails, the user can run it manually.\n2. **Cleanup old versions**: Remove old version directories, keeping the 5 most recent by **creation time** (matching `install.sh` behavior). The new version and the previous version are always protected from cleanup, even if they fall outside the top 5 (e.g., after a downgrade via `--rollback`).\n\n#### Step 7: Running Binary Consideration\n\nThe running `vp` process is **not** the binary being replaced. The flow is:\n\n```\n# Unix\n~/.vite-plus/bin/vp  →  ../current/bin/vp  →  {old_version}/bin/vp\n\n# Windows\n~/.vite-plus/bin/vp.exe (trampoline)  →  current\\bin\\vp.exe  →  {old_version}\\bin\\vp.exe\n```\n\nAfter the `current` link swap, any **new** invocation of `vp` will use the new binary. The currently running process continues to execute from the old version's binary file on disk:\n\n- **Unix**: The old binary remains valid because Unix doesn't delete open files until all file descriptors are closed\n- **Windows**: The old `.exe` file is locked while running, but since we install to a **new version directory** (not overwriting in-place), there's no conflict. The old version directory is preserved (kept in the \"last 5\" cleanup policy)\n\n### Rollback Design\n\nThe `--rollback` flag switches the `current` symlink to the previously active version.\n\nTo track the previous version, we can:\n\n1. Read the `current` symlink target before updating\n2. After the update, write the previous version to `~/.vite-plus/.previous-version`\n\nFor `--rollback`:\n\n1. Read `~/.vite-plus/.previous-version`\n2. Verify that version directory still exists\n3. Swap `current` symlink to point to it\n4. Update `.previous-version` to point to the version we just rolled back from\n\n### Error Handling\n\n| Error                           | Recovery                                                      |\n| ------------------------------- | ------------------------------------------------------------- |\n| Network failure during download | Clean up partial temp files, exit with helpful message        |\n| Integrity mismatch (SHA-512)    | Delete downloaded file, report expected vs actual hash, abort |\n| Corrupted tarball               | Verify extraction success, clean up version dir if partial    |\n| `vp install` fails              | Remove the version dir, keep current version unchanged        |\n| Disk full                       | Detect and report, clean up partial state                     |\n| Permission denied               | Report with suggestion to check directory ownership           |\n| Registry returns error          | Parse npm error JSON, show human-readable message             |\n\nKey principle: **The `current` symlink is only swapped after all pre-swap steps succeed.** If any pre-swap step fails, the existing installation is untouched. Post-swap operations (shim refresh, old version cleanup) are non-fatal — their errors are printed to stderr as warnings but do not roll back the update.\n\n### File Structure\n\n```\ncrates/vite_global_cli/\n├── src/\n│   ├── commands/\n│   │   ├── upgrade/\n│   │   │   ├── mod.rs        # Module root, public execute() function\n│   │   │   ├── registry.rs   # npm registry client (version resolution, tarball URLs)\n│   │   │   ├── platform.rs   # Platform detection (os, arch, libc)\n│   │   │   ├── download.rs   # HTTP download + tarball extraction\n│   │   │   └── install.rs    # Extract, dependency install, symlink swap, cleanup\n│   │   ├── mod.rs            # Add upgrade module\n│   │   └── ...\n│   └── cli.rs                # Add Upgrade command variant\n```\n\n### Platform Detection\n\n```rust\nfn detect_platform() -> Result<String, Error> {\n    let os = std::env::consts::OS;       // \"macos\", \"linux\", \"windows\"\n    let arch = std::env::consts::ARCH;   // \"x86_64\", \"aarch64\"\n\n    let os_name = match os {\n        \"macos\" => \"darwin\",\n        \"linux\" => \"linux\",\n        \"windows\" => \"win32\",\n        _ => return Err(Error::UnsupportedPlatform(os.into())),\n    };\n\n    let arch_name = match arch {\n        \"x86_64\" => \"x64\",\n        \"aarch64\" => \"arm64\",\n        _ => return Err(Error::UnsupportedArch(arch.into())),\n    };\n\n    if os_name == \"linux\" {\n        let libc = detect_libc(); // \"gnu\" or \"musl\"\n        Ok(format!(\"{os_name}-{arch_name}-{libc}\"))\n    } else if os_name == \"win32\" {\n        Ok(format!(\"{os_name}-{arch_name}-msvc\"))\n    } else {\n        Ok(format!(\"{os_name}-{arch_name}\"))\n    }\n}\n```\n\n### Registry Client\n\nUses `reqwest` (already a dependency via `vite_js_runtime`) for HTTP requests:\n\n```rust\nasync fn resolve_version(registry: &str, version_or_tag: &str) -> Result<PackageMetadata, Error> {\n    let url = format!(\"{}/vite-plus-cli/{}\", registry, version_or_tag);\n    let response = reqwest::get(&url).await?.json::<PackageMetadata>().await?;\n    Ok(response)\n}\n```\n\n### CLI Integration\n\nAdd `Upgrade` to the `Commands` enum in `cli.rs`:\n\n```rust\n/// Update vp itself to the latest version\n#[command(name = \"upgrade\", visible_alias = \"upgrade\")]\nUpgrade {\n    /// Target version (default: latest)\n    version: Option<String>,\n\n    /// npm dist-tag (default: \"latest\")\n    #[arg(long, default_value = \"latest\")]\n    tag: String,\n\n    /// Check for updates without installing\n    #[arg(long)]\n    check: bool,\n\n    /// Revert to previous version\n    #[arg(long)]\n    rollback: bool,\n\n    /// Force reinstall even if up to date\n    #[arg(long)]\n    force: bool,\n\n    /// Suppress output\n    #[arg(long)]\n    silent: bool,\n\n    /// Custom npm registry URL\n    #[arg(long)]\n    registry: Option<String>,\n},\n```\n\n## Design Decisions\n\n### 1. Command Name: `upgrade`\n\n**Decision**: Use `vp upgrade` (with hyphen).\n\n**Alternatives considered**:\n\n- `vp upgrade` — used by Deno, Bun, proto; shorter but ambiguous with `vp update` (packages)\n- `vp self upgrade` — used by rustup (`rustup self update`); requires subcommand group\n\n**Rationale**:\n\n- Matches pnpm (`pnpm upgrade`) and mise (`mise upgrade`) conventions\n- Zero ambiguity with `vp update` (which updates npm packages)\n- The hyphen is consistent with `list-remote` in `vp env`\n- Tools without upgrade (fnm, volta, nvm) require re-running install scripts — worse UX\n- `upgrade` is registered as a visible alias, so `vp upgrade` also works (matches Deno/Bun/proto users' expectations)\n\n### 2. Pure Rust Implementation (No Shell Script Re-execution)\n\n**Decision**: Implement the update logic entirely in Rust.\n\n**Rationale**:\n\n- No dependency on bash or curl being installed\n- Better error handling and progress reporting\n- Consistent behavior across platforms\n- The install.sh script remains for first-time installation only\n\n### 3. Reuse npm Distribution Channel\n\n**Decision**: Download tarballs from the same npm registry used by `install.sh`.\n\n**Rationale**:\n\n- No new infrastructure needed\n- Same release pipeline, same artifacts\n- Supports custom registries and mirrors via `--registry` or `NPM_CONFIG_REGISTRY`\n- Users behind corporate proxies already have npm registry access configured\n\n### 4. No Automatic Update Checks\n\n**Decision**: Do not check for updates on every `vp` invocation.\n\n**Rationale**:\n\n- Avoids unexpected network requests that slow down commands\n- Avoids privacy concerns (phoning home on every run)\n- Users can opt into periodic checks via their own cron/launchd if desired\n- This can be revisited as a future enhancement with proper opt-in\n\n### 5. Keep 5 Versions for Rollback\n\n**Decision**: Maintain the same cleanup policy as `install.sh` (keep 5 most recent versions by creation time, with protected versions).\n\n**Rationale**:\n\n- Consistent with existing `install.sh` behavior (sorts by creation time, not semver)\n- Provides rollback safety net without unbounded disk usage\n- Each version is ~20-30MB, so 5 versions is ~100-150MB total\n- The active version and previous version are always protected from cleanup, preventing accidental deletion after a downgrade\n\n## Implementation Phases\n\n### Phase 0 (P0): Core Self-Update\n\n**Scope:**\n\n- `vp upgrade` — downloads and installs the latest version\n- `vp upgrade <version>` — installs a specific version\n- `--tag`, `--force`, `--silent` flags\n- Platform detection, npm registry query, download, extract, symlink swap\n- Version cleanup (keep 5)\n- Error handling with clean rollback\n\n**Files to create/modify:**\n\n- `crates/vite_global_cli/src/commands/upgrade/mod.rs` (new)\n- `crates/vite_global_cli/src/commands/upgrade/registry.rs` (new)\n- `crates/vite_global_cli/src/commands/upgrade/platform.rs` (new)\n- `crates/vite_global_cli/src/commands/upgrade/download.rs` (new)\n- `crates/vite_global_cli/src/commands/upgrade/install.rs` (new)\n- `crates/vite_global_cli/src/commands/mod.rs` (add module)\n- `crates/vite_global_cli/src/cli.rs` (add command variant + routing)\n\n**Success Criteria:**\n\n- [ ] `vp upgrade` downloads and installs the latest version\n- [ ] `vp upgrade 0.x.y` installs a specific version\n- [ ] Downloaded tarballs are verified against npm registry `integrity` (SHA-512)\n- [ ] Running binary is not affected during update\n- [ ] Failed update leaves the current installation untouched\n- [ ] Old versions are cleaned up (max 5 retained)\n- [ ] Works on macOS, Linux, and Windows\n\n### Phase 1 (P1): Rollback and Check\n\n**Scope:**\n\n- `--rollback` flag with `.previous-version` tracking\n- `--check` flag for update availability check\n\n**Success Criteria:**\n\n- [ ] `vp upgrade --rollback` reverts to previous version\n- [ ] `vp upgrade --check` shows available update without installing\n\n### Phase 2 (P2): Enhanced UX\n\n**Scope:**\n\n- Progress bar for downloads (using `indicatif` or similar)\n- Release notes URL in update success message\n- `--registry` flag for custom npm registry\n\n**Success Criteria:**\n\n- [ ] Download progress is visible for large binaries\n- [ ] Release notes link is shown after successful update\n\n## Testing Strategy\n\n### Unit Tests\n\n- Version comparison logic (semver parsing, equality, ordering)\n- Platform detection (mock `std::env::consts`)\n- Registry URL construction\n- Symlink swap atomicity\n\n### Integration Tests\n\n- Download and extract a real package from the test npm tag\n- Verify version directory structure after install\n- Verify `current` symlink points to new version\n- Verify old version cleanup\n\n### Snap Tests\n\n```bash\n# Test: upgrade check (mock registry response)\npnpm -F vite-plus-cli snap-test upgrade-check\n\n# Test: upgrade to specific version\npnpm -F vite-plus-cli snap-test upgrade-version\n```\n\n### Manual Testing\n\n```bash\n# Build and install current version\npnpm bootstrap-cli\n\n# Run upgrade to latest published version\nvp upgrade\n\n# Verify version changed\nvp -V\n\n# Test rollback\nvp upgrade --rollback\nvp -V\n```\n\n## Future Enhancements\n\n- **Automatic update check**: Periodic background check with opt-in notification (e.g., once per day, cached result)\n- **Update channels**: Allow pinning to a channel (stable, beta, nightly) via config file\n- **Delta updates**: Download only changed files instead of full tarballs\n- **Windows support**: Extend to PowerShell-based update mechanism for Windows native installs\n\n## References\n\n- [RFC: Global CLI (Rust Binary)](./global-cli-rust-binary.md)\n- [RFC: Split Global CLI](./split-global-cli.md)\n- [RFC: Env Command](./env-command.md)\n- [Install Script](../packages/cli/install.sh)\n- [Release Workflow](../.github/workflows/release.yml)\n"
  },
  {
    "path": "rfcs/vpx-command.md",
    "content": "# RFC: `vpx` Command\n\n## Summary\n\nAdd `vpx` command that runs a command from a local, globally installed, or remote npm package (like `npx`), with a multi-step resolution chain before falling back to remote download.\n\nThe existing `vp dlx` command remains unchanged — it always downloads from the registry without checking local packages (like `pnpm dlx`).\n\n## Motivation\n\nCurrently, `vp dlx` always downloads packages from the remote registry, even when the desired binary already exists in `node_modules/.bin`. There is no way to run a locally installed package binary with automatic remote fallback.\n\nEvery major package manager provides this capability:\n\n```bash\n# npm - checks local, falls back to remote\nnpx eslint .\n\n# pnpm - local only (no remote fallback)\npnpm exec eslint .\n\n# bun - checks local, falls back to remote\nbunx eslint .\n```\n\n### Current Pain Points\n\n```bash\n# Developer has eslint installed locally, but vp dlx always downloads it again\nvp dlx eslint .                     # Downloads from registry (slow, wasteful)\n\n# To run local binary, developer must use full path\n./node_modules/.bin/eslint .        # Verbose, not portable\n\n# Or use the underlying package manager\npnpm exec eslint .                  # Defeats the purpose of vp\n```\n\n### Proposed Solution\n\n```bash\n# Uses local eslint if installed, otherwise downloads\nvpx eslint .\n\n# Always downloads from registry (unchanged)\nvp dlx eslint .\n```\n\n## Command Syntax\n\n```bash\nvpx <pkg>[@<version>] [args...]\nvpx --package=<pkg>[@<version>] <cmd> [args...]\nvpx -c '<cmd> [args...]'\n```\n\nAll flags must come before positional arguments (like `npx`).\n\n**Options:**\n\n- `--package, -p <name>`: Specifies which package(s) to install if not found locally. Can be specified multiple times.\n- `--shell-mode, -c`: Executes the command within a shell environment (`/bin/sh` on UNIX, `cmd.exe` on Windows).\n- `--silent, -s`: Suppresses all output except the executed command's output.\n\n### Usage Examples\n\n```bash\n# Run locally installed binary (or download if not found)\nvpx eslint .\n\n# Run specific version (always remote — version doesn't match local)\nvpx typescript@5.5.4 tsc --version\n\n# Separate package and command (when binary name differs from package name)\nvpx --package @pnpm/meta-updater meta-updater --help\n\n# Multiple packages\nvpx --package yo --package generator-webapp yo webapp\n\n# Shell mode (pipe commands)\nvpx -p cowsay -p lolcatjs -c 'echo \"hi vp\" | cowsay | lolcatjs'\n\n# Silent mode\nvpx -s create-vue my-app\n```\n\n## Lookup Order\n\nWhen `vpx` is invoked:\n\n1. **Walk up from cwd** looking for `node_modules/.bin/<cmd>`\n   - Check `./node_modules/.bin/<cmd>`\n   - Check `../node_modules/.bin/<cmd>`\n   - Continue until reaching the filesystem root\n2. **Check vp global packages** (installed via `vp install -g`)\n   - Uses `BinConfig` for O(1) lookup of which package provides the binary\n   - Executes with the Node.js version used at install time\n3. **Check system PATH** (excluding vite-plus bin directory)\n   - Filters out `~/.vite-plus/bin/` to avoid finding vite-plus shims\n   - Finds commands like `git`, `cargo`, etc. without downloading\n4. **Fall back to remote download** via `vp dlx` behavior (remote download via detected package manager)\n\nBefore executing any found binary, `vpx` prepends all `node_modules/.bin` directories (from cwd upward) to PATH so that sub-processes also resolve local binaries first.\n\n### Special Cases\n\n- When a version is specified (e.g., `vpx eslint@9`), local/global/PATH lookup is skipped — always use remote\n- When only a package name is specified without a version (e.g., `vpx eslint`), prefer local if available\n- Shell mode (`-c`) skips local/global/PATH lookup and delegates directly to `vp dlx`\n- `--package` flag skips local/global/PATH lookup and delegates directly to `vp dlx`\n\n## Relationship Between Commands\n\n| Command  | Local lookup | Global lookup | PATH lookup | Remote download | Use case                                          |\n| -------- | ------------ | ------------- | ----------- | --------------- | ------------------------------------------------- |\n| `vpx`    | Yes (1st)    | Yes (2nd)     | Yes (3rd)   | Yes (fallback)  | Run local, global, PATH, or remote package binary |\n| `vp dlx` | No           | No            | No          | Always          | Always fetch latest from registry                 |\n\n### When to use which\n\n- **`vpx eslint .`** — \"Run eslint, preferring my local version\"\n- **`vp dlx create-vue my-app`** — \"Download and run create-vue from the registry\"\n- **`vpx create-vue my-app`** — Same as `vp dlx` in practice, since `create-vue` is never installed locally\n\n## Binary Implementation\n\n### Symlink Approach\n\n`vpx` is delivered as a symlink to `vp`, detected via `argv[0]`:\n\n```\n~/.vite-plus/bin/vpx → ~/.vite-plus/bin/vp   (symlink)\n```\n\nThis follows the same pattern already used for `node`, `npm`, and `npx` shims.\n\n### Detection\n\nIn `shim/mod.rs`, when `argv[0]` resolves to `vpx`:\n\n```rust\nlet argv0_tool = extract_tool_name(argv0);\nif argv0_tool == \"vpx\" {\n    return Some(\"vpx\".to_string());\n}\n```\n\nIn `shim/dispatch.rs`, `vpx` is handled early and delegates to `commands/vpx.rs`:\n\n```rust\nif tool == \"vpx\" {\n    return crate::commands::vpx::execute_vpx(args, &cwd).await;\n}\n```\n\n### Windows\n\nOn Windows, `vpx.exe` is a trampoline executable (consistent with existing `node.exe`, `npm.exe`, `npx.exe` shims). It detects its tool name from its own filename (`vpx`), sets `VITE_PLUS_SHIM_TOOL=vpx`, and spawns `vp.exe`. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md).\n\n### Setup\n\nThe `vp env setup` command creates the `vpx` symlink/wrapper alongside existing shims:\n\n```\n~/.vite-plus/bin/\n├── vp          → ../current/bin/vp\n├── vpx         → vp                   ← NEW\n├── node        → vp\n├── npm         → vp\n└── npx         → vp\n```\n\n## Comparison with npx\n\n| Behavior            | `npx`                                      | `vpx`                                              |\n| ------------------- | ------------------------------------------ | -------------------------------------------------- |\n| Local lookup        | Walk up `node_modules/.bin`                | Walk up `node_modules/.bin`                        |\n| Global lookup       | Checks npm global installs                 | Checks vp global packages (`vp install -g`)        |\n| PATH lookup         | Checks system PATH                         | Checks system PATH (excluding `~/.vite-plus/bin/`) |\n| Remote fallback     | Download to npm cache                      | Delegate to `vp dlx` (uses detected PM)            |\n| Confirmation prompt | Prompts before installing unknown packages | Auto-confirms (like `vp dlx` with `--yes`)         |\n| `--package` flag    | Specifies additional packages              | Same                                               |\n| Shell mode (`-c`)   | Runs in shell with packages in PATH        | Same                                               |\n| Cache               | npm cache                                  | Package manager's cache (via `vp dlx`)             |\n\n### Key Difference: Auto-confirm\n\n`npx` prompts the user before downloading unknown packages. `vpx` always auto-confirms (aligns with `vp dlx` behavior and pnpm's approach). This avoids inconsistent behavior across package managers.\n\n## Design Decisions\n\n### 1. Why Walk Up Directories\n\n**Decision**: Walk up from cwd to filesystem root looking for `node_modules/.bin`, like `npx`.\n\n**Rationale**:\n\n- In monorepos, a command may be installed at the workspace root, not the current package\n- `npx` walks up directories — matching this behavior meets developer expectations\n- `pnpm exec` only looks in `./node_modules/.bin` — too restrictive for monorepos\n\n### 2. Why `vpx` is Separate from `vp dlx`\n\n**Decision**: Keep `vpx` (local-first) and `vp dlx` (remote-only) as separate commands.\n\n**Rationale**:\n\n- Different mental models: \"run what I have\" vs \"download and run\"\n- `vp dlx` already exists with well-defined remote-only behavior — changing it would break expectations\n- Explicit is better than implicit — developers should choose their intent\n\n### 3. Why `vpx` is a Symlink\n\n**Decision**: `vpx` is a symlink to `vp`, not a separate binary.\n\n**Rationale**:\n\n- Zero additional binary size\n- Same pattern used for `node`/`npm`/`npx` shims — proven approach\n- `argv[0]` detection is already implemented in `shim/mod.rs`\n- Single binary to update when upgrading\n\n### 4. Why Not Add `vp exec` Subcommand\n\n**Decision**: Only provide `vpx` as a standalone command, no `vp exec` subcommand for now.\n\n**Rationale**:\n\n- `vpx` covers the primary use case — quick execution of local/remote binaries\n- Adding `vp exec` introduces complexity (argument parsing with `--` separator, potential confusion with `vp env exec`)\n- `vp exec` can be added later as a follow-up if needed\n- Keeps the initial implementation simple and focused\n\n## Edge Cases\n\n### Monorepo Sub-packages\n\nWhen running `vpx eslint` from `packages/app/`:\n\n```\nmonorepo/\n├── node_modules/.bin/eslint    ← found here (workspace root)\n├── packages/\n│   └── app/\n│       └── node_modules/.bin/  ← checked first (empty)\n└── package.json\n```\n\nThe walker continues up from cwd until it finds the binary or reaches the filesystem root.\n\n### Native vs JS Binaries\n\nBoth native (compiled) and JS binaries in `node_modules/.bin` are supported. The lookup only checks for file existence and executability, not file type.\n\nFor globally installed packages, the metadata tracks whether a binary is JavaScript (`js_bins` field in `PackageMetadata`). JS binaries are executed via `node <path>`, while native binaries are executed directly.\n\n### Platform Differences\n\n- **Unix**: `node_modules/.bin/<cmd>` is typically a symlink to the package's bin script\n- **Windows**: `node_modules/.bin/<cmd>.cmd` wrapper scripts — lookup checks for `.cmd` extension\n\n### Version Mismatch\n\n```bash\n# Local eslint is v8, but user wants v9\nvpx eslint@9 .\n# → Version specified, so local/global/PATH lookup is skipped → delegates to vp dlx\n```\n\nWhen a version is explicitly specified in the package spec, the command skips all local resolution and always uses remote download.\n\n## Implementation Architecture\n\n### 1. Shim Detection\n\n**File**: `crates/vite_global_cli/src/shim/mod.rs`\n\nAdd `vpx` recognition to `detect_shim_tool()`:\n\n```rust\nlet argv0_tool = extract_tool_name(argv0);\nif argv0_tool == \"vp\" {\n    return None;\n}\nif argv0_tool == \"vpx\" {\n    return Some(\"vpx\".to_string());\n}\n```\n\n### 2. Dispatch Handler\n\n**File**: `crates/vite_global_cli/src/shim/dispatch.rs`\n\nHandle `vpx` in the dispatch logic (delegates to `commands/vpx.rs`):\n\n```rust\nif tool == \"vpx\" {\n    return crate::commands::vpx::execute_vpx(args, &cwd).await;\n}\n```\n\nThe dispatch module also exposes helper functions as `pub(crate)` for vpx to reuse:\n\n- `find_package_for_binary()` — looks up which globally installed package provides a binary\n- `locate_package_binary()` — locates the actual binary path inside a package\n- `ensure_installed()` — ensures a Node.js version is downloaded\n- `locate_tool()` — locates a tool binary within a Node.js installation\n\n### 3. Binary Resolution (`commands/vpx.rs`)\n\n**File**: `crates/vite_global_cli/src/commands/vpx.rs`\n\nResolution order (when no version spec, no --package flag, and not shell mode):\n\n```rust\n// 1. Local node_modules/.bin — walk up from cwd\nif let Some(local_bin) = find_local_binary(cwd, &cmd_name) { ... }\n\n// 2. Global vp packages — uses dispatch::find_package_for_binary()\nif let Some(global_bin) = find_global_binary(&cmd_name).await { ... }\n\n// 3. System PATH — uses which::which_in() with filtered PATH\nif let Some(path_bin) = find_on_path(&cmd_name) { ... }\n\n// 4. Remote download — delegates to DlxCommand\n```\n\nBefore executing any found binary, `prepend_node_modules_bin_to_path()` walks up from cwd and prepends all existing `node_modules/.bin` directories to PATH.\n\n### 4. Setup\n\n**File**: `crates/vite_global_cli/src/commands/env/setup.rs`\n\nAdd `vpx` to the shim creation:\n\n```rust\n// After creating vp symlink, also create vpx\ncreate_symlink(&bin_dir.join(\"vpx\"), &bin_dir.join(\"vp\")).await?;\n```\n\n### 5. Reuses Existing `DlxCommand`\n\nThe remote fallback path delegates entirely to the existing `DlxCommand`, which handles package manager detection, command resolution, and execution. No changes needed to `vp dlx` behavior.\n\n## CLI Help Output\n\n```bash\n$ vpx --help\nExecute a command from a local or remote npm package\n\nUsage: vpx [OPTIONS] <pkg[@version]> [args...]\n\nArguments:\n  <pkg[@version]>  Package binary to execute\n  [args...]        Arguments to pass to the command\n\nOptions:\n  -p, --package <NAME>  Package(s) to install if not found locally\n  -c, --shell-mode      Execute the command within a shell environment\n  -s, --silent          Suppress all output except the command's output\n  -h, --help            Print help\n\nExamples:\n  vpx eslint .                                           # Run local eslint (or download)\n  vpx create-vue my-app                                  # Download and run create-vue\n  vpx typescript@5.5.4 tsc --version                     # Run specific version\n  vpx -p cowsay -c 'echo \"hi\" | cowsay'                  # Shell mode with package\n```\n\n## Error Handling\n\n### Missing Command\n\n```bash\n$ vpx\nError: vpx requires a command to run\n\nUsage: vpx <pkg[@version]> [args...]\n\nExamples:\n  vpx eslint .\n  vpx create-vue my-app\n```\n\n### Not Found Locally or Globally (Falls Back to Remote)\n\n```bash\n$ vpx some-tool --version\n# Not found in node_modules/.bin, global packages, or PATH\n# Falls back to remote download via vp dlx\nRunning: pnpm dlx some-tool --version\nsome-tool v1.2.3\n```\n\n### No package.json\n\n```bash\n$ cd /tmp\n$ vpx cowsay hello\n# No package.json — vpx delegates to vp dlx, which falls back to npx\n _______\n< hello >\n -------\n        \\   ^__^\n         \\  (oo)\\_______\n            (__)\\       )\\/\\\n                ||----w |\n                ||     ||\n```\n\n`vpx` works in directories without a `package.json` because `vp dlx` falls back to `npx` when no package manager can be detected.\n\n### Remote Package Not Found\n\n```bash\n$ vpx non-existent-package-xyz\n# Not found anywhere, remote download also fails\nRunning: pnpm dlx non-existent-package-xyz\n ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND  No package.json was found\nExit code: 1\n```\n\n## Security Considerations\n\n1. **Local-first is safer**: `vpx` prefers local binaries, reducing the risk of running unexpected remote code for packages that are already project dependencies.\n\n2. **Global packages are trusted**: Globally installed packages (via `vp install -g`) were explicitly installed by the user, so executing them is safe.\n\n3. **PATH lookup excludes vite-plus shims**: The PATH search filters out `~/.vite-plus/bin/` to prevent `vpx` from finding itself or other managed shims.\n\n4. **Auto-confirm for remote**: When falling back to remote download, `vpx` auto-confirms (like `vp dlx`). This means unknown packages are downloaded without prompting — consistent with `vp dlx` behavior.\n\n5. **Version pinning**: Specifying an explicit version (e.g., `vpx eslint@9`) bypasses all local resolution and always downloads from the registry, ensuring the exact requested version is used.\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- `vp dlx` behavior is completely unchanged\n- `vpx` binary is a new symlink created by `vp env setup`\n- Existing `node`/`npm`/`npx` shims are unaffected\n- No changes to configuration format\n\n## Future Enhancements\n\n### 1. `vp exec` Subcommand\n\nAdd `vp exec` as an alternative way to invoke `vpx` from within `vp`, using `--` separator for argument parsing (like `npm exec`).\n\n### 2. Workspace-aware Lookup\n\n```bash\nvpx --workspace=app eslint .    # Look in app's node_modules first\n```\n\n### 3. Local-only / Remote-only Modes\n\n```bash\nvpx --prefer-local eslint .     # Only use local, never download\nvpx --prefer-remote eslint .    # Always download, ignore local\n```\n\n## Conclusion\n\nThis RFC proposes adding `vpx` to complete the package execution story in Vite+:\n\n- `vp dlx` — always remote (like `pnpm dlx`)\n- `vpx` — local-first with global and PATH fallback, then remote (like `npx`)\n\nThe design:\n\n- Follows established `npx` conventions for familiar developer experience\n- Reuses existing `vp dlx` infrastructure for the remote fallback path\n- Uses the proven symlink + `argv[0]` detection pattern for delivery\n- Maintains clear separation between local-first (`vpx`) and remote-only (`vp dlx`)\n- Is purely additive with no breaking changes to existing behavior\n"
  },
  {
    "path": "rfcs/why-package-command.md",
    "content": "# RFC: Vite+ Why Package Command\n\n## Summary\n\nAdd `vp why` (alias: `vp explain`) command that automatically adapts to the detected package manager (pnpm/npm/yarn) for showing all packages that depend on a specified package. This helps developers understand dependency relationships, audit package usage, and debug dependency tree issues.\n\n## Motivation\n\nCurrently, developers must manually use package manager-specific commands to understand why a package is installed:\n\n```bash\npnpm why <package>\nnpm explain <package>\nyarn why <package>\n```\n\nThis creates friction in dependency analysis workflows and requires remembering different syntaxes. A unified interface would:\n\n1. **Simplify dependency analysis**: One command works across all package managers\n2. **Auto-detection**: Automatically uses the correct package manager\n3. **Consistency**: Same syntax regardless of underlying tool\n4. **Integration**: Works seamlessly with existing Vite+ features\n\n### Current Pain Points\n\n```bash\n# Developer needs to know which package manager is used\npnpm why react                    # pnpm project\nnpm explain react                 # npm project (different command name)\nyarn why react                    # yarn project\n\n# Different output formats\npnpm why react --json             # pnpm - JSON output\nnpm explain react --json          # npm - JSON output\nyarn why react                    # yarn - custom format\n\n# Different workspace targeting\npnpm why react --filter app       # pnpm - filter workspaces\nnpm explain react --workspace app # npm - specify workspace\nyarn why react                    # yarn - no workspace filtering\n```\n\n### Proposed Solution\n\n```bash\n# Works for all package managers\nvp why <package>                # Show why package is installed\nvp explain <package>            # Alias (matches npm)\n\n# Output formats\nvp why react --json             # JSON output\nvp why react --long             # Verbose output\nvp why react --parseable        # Parseable format\n\n# Workspace operations\nvp why react --filter app       # Check in specific workspace (pnpm)\nvp why react -r                 # Check recursively across workspaces\n\n# Dependency type filtering\nvp why react --prod             # Only production dependencies\nvp why react --dev              # Only dev dependencies\nvp why react --depth 2          # Limit tree depth\n```\n\n## Proposed Solution\n\n### Command Syntax\n\n#### Why Command\n\n```bash\nvp why <PACKAGE> [OPTIONS]\nvp explain <PACKAGE> [OPTIONS]        # Alias\n```\n\n**Examples:**\n\n```bash\n# Basic usage\nvp why react\nvp explain lodash\n\n# Multiple packages (pnpm style)\nvp why react react-dom\nvp why \"babel-*\" \"eslint-*\"\n\n# Output formats\nvp why react --json             # JSON output\nvp why react --long             # Verbose output\nvp why react --parseable        # Parseable output\n\n# Workspace operations\nvp why react -r                 # Recursive across all workspaces\nvp why react --filter app       # Check in specific workspace (pnpm)\n\n# Dependency type filtering\nvp why react --prod             # Only production dependencies\nvp why react --dev              # Only dev dependencies\nvp why react --no-optional      # Exclude optional dependencies\n\n# Depth control\nvp why react --depth 3          # Limit tree depth to 3 levels\n\n# Global packages\nvp why typescript -g            # Check globally installed packages\n\n# Custom finder (pnpm only)\nvp why react --find-by myFinder # Use finder function from .pnpmfile.cjs\n```\n\n### Global Packages Checking\n\nOnly use `npm` to check globally installed packages, because `vp install -g` uses `npm` cli to install global packages.\n\n```bash\nvp why typescript -g            # Check globally installed packages\n\n-> npm why typescript -g\n```\n\n### Command Mapping\n\n#### Why Command Mapping\n\n**pnpm references:**\n\n- https://pnpm.io/cli/why\n- Shows all packages that depend on the specified package\n\n**npm references:**\n\n- https://docs.npmjs.com/cli/v11/commands/npm-explain\n- Explains why a package is installed (alias: `npm why`)\n\n**yarn references:**\n\n- https://classic.yarnpkg.com/en/docs/cli/why (yarn@1)\n- https://yarnpkg.com/cli/why (yarn@2+)\n- Identifies why a package has been installed\n\n| Vite+ Flag                | pnpm                      | npm                     | yarn@1              | yarn@2+                  | Description                                                     |\n| ------------------------- | ------------------------- | ----------------------- | ------------------- | ------------------------ | --------------------------------------------------------------- |\n| `vp why <pkg>`            | `pnpm why <pkg>`          | `npm explain <pkg>`     | `yarn why <pkg>`    | `yarn why <pkg> --peers` | Show why package is installed                                   |\n| `--json`                  | `--json`                  | `--json`                | `--json`            | `--json`                 | JSON output format                                              |\n| `--long`                  | `--long`                  | N/A                     | N/A                 | N/A                      | Verbose output (pnpm only)                                      |\n| `--parseable`             | `--parseable`             | N/A                     | N/A                 | N/A                      | Parseable format (pnpm only)                                    |\n| `-r, --recursive`         | `-r, --recursive`         | N/A                     | N/A                 | `--recursive`            | Check across all workspaces                                     |\n| `--filter <pattern>`      | `--filter <pattern>`      | `--workspace <pattern>` | N/A                 | N/A                      | Target specific workspace (pnpm/npm)                            |\n| `-w, --workspace-root`    | `-w`                      | N/A                     | N/A                 | N/A                      | Check in workspace root (pnpm-specific)                         |\n| `-P, --prod`              | `-P, --prod`              | N/A                     | N/A                 | N/A                      | Only production dependencies (pnpm only)                        |\n| `-D, --dev`               | `-D, --dev`               | N/A                     | N/A                 | N/A                      | Only dev dependencies (pnpm only)                               |\n| `--depth <number>`        | `--depth <number>`        | N/A                     | N/A                 | N/A                      | Limit tree depth (pnpm only)                                    |\n| `--no-optional`           | `--no-optional`           | N/A                     | `--ignore-optional` | N/A                      | Exclude optional dependencies (pnpm only)                       |\n| `-g, --global`            | `-g, --global`            | N/A                     | N/A                 | N/A                      | Check globally installed packages                               |\n| `--exclude-peers`         | `--exclude-peers`         | N/A                     | N/A                 | Removes `--peers` flag   | Exclude peer dependencies (yarn@2+ defaults to including peers) |\n| `--find-by <finder_name>` | `--find-by <finder_name>` | N/A                     | N/A                 | N/A                      | Use finder function from .pnpmfile.cjs                          |\n\n**Note:**\n\n- npm uses `explain` as the primary command, `why` as alias, supports multiple packages\n- pnpm uses `why` as the primary command, supports multiple packages and glob patterns\n- yarn has `why` command in both v1 and v2+, but different output formats, only supports single package\n- pnpm has the most comprehensive filtering and output options\n- npm has simpler output focused on the dependency path\n\n**Aliases:**\n\n- `vp explain` = `vp why` (matches npm's primary command name)\n\n### Why Behavior Differences Across Package Managers\n\n#### pnpm\n\n**Why behavior:**\n\n- Shows all packages that depend on the specified package\n- Supports multiple packages and glob patterns: `pnpm why babel-* eslint-*`\n- Displays dependency tree with complete paths\n- Truncates output after 10 end leaves to prevent memory issues\n- Supports workspace filtering with `--filter`\n- Can filter by dependency type (prod, dev, optional)\n- Supports depth limiting\n- Can check global packages with `-g`\n\n**Output format:**\n\n```\nLegend: production dependency, optional only, dev only\n\npackage-a@1.0.0 /path/to/package-a\n└── react@18.3.1\n    └── react-dom@18.3.1\n\npackage-b@2.0.0 /path/to/package-b\n└─┬ @testing-library/react@14.0.0\n  └── react@18.3.1\n```\n\n**Options:**\n\n- `--json`: JSON format\n- `--long`: Extended information\n- `--parseable`: Parseable format (no tree structure)\n- `-r`: Recursive across workspaces\n- `--filter`: Workspace filtering\n- `--prod`/`--dev`: Dependency type filtering\n- `--depth`: Limit tree depth\n- `--exclude-peers`: Exclude peer dependencies\n\n#### npm\n\n**Explain behavior:**\n\n- Shows the dependency path for why a package is installed\n- Primary command is `explain`, `why` is an alias\n- Simple, focused output showing dependency chain\n- Supports workspace targeting with `--workspace`\n- JSON output available\n\n**Output format:**\n\n```\nreact@18.3.1\nnode_modules/react\n  react@\"^18.3.1\" from react-dom@18.3.1\n  node_modules/react-dom\n    react-dom@\"^18.3.1\" from the root project\n  react@\"^18.3.1\" from @testing-library/react@14.0.0\n  node_modules/@testing-library/react\n    @testing-library/react@\"^14.0.0\" from the root project\n```\n\n**Options:**\n\n- `--json`: JSON format\n- `--workspace`: Target specific workspace\n\n#### yarn@1 (Classic)\n\n**Why behavior:**\n\n- Identifies why a package has been installed\n- Shows which packages depend on it\n- Displays disk size information (with and without dependencies)\n- Shows whether package is hoisted\n- Can accept package name, folder path, or file path\n\n**Output format:**\n\n```\n[1/4] 🤔  Why do we have the package \"jest\"?\n[2/4] 🚚  Required dependencies\ninfo Reasons this module exists\n   - \"@my/package#devDependencies\" depends on it\n   - Hoisted from \"@my/package#jest\"\n[3/4] 💾  Disk size without dependencies: \"0B\"\n[4/4] 📦  Dependencies using this package\n```\n\n**Options:**\n\n- No command-line options\n- Single package only\n\n#### yarn@2+ (Berry)\n\n**Why behavior:**\n\n- Shows why a package is present in the dependency tree\n- More streamlined output than yarn@1\n- Supports recursive workspace checking\n- Includes peer dependencies by default (uses `--peers` flag)\n- Use `--exclude-peers` to remove the `--peers` flag\n\n**Output format:**\n\n```\n➤ YN0000: react@npm:18.3.1\n➤ YN0000: └ Required by: react-dom@npm:18.3.1\n➤ YN0000: └ Required by: @testing-library/react@npm:14.0.0\n```\n\n**Options:**\n\n- `--recursive`: Check across workspaces\n- `--peers`: Include peer dependencies (added by default via Vite+)\n- Different plugin system may affect output\n\n### Implementation Architecture\n\n#### 1. Command Structure\n\n**File**: `crates/vite_task/src/lib.rs`\n\nAdd new command variant:\n\n```rust\n#[derive(Subcommand, Debug)]\npub enum Commands {\n    // ... existing commands\n\n    /// Show why a package is installed\n    #[command(disable_help_flag = true, alias = \"explain\")]\n    Why {\n        /// Package(s) to check\n        packages: Vec<String>,\n\n        /// Output in JSON format\n        #[arg(long)]\n        json: bool,\n\n        /// Show extended information (pnpm only)\n        #[arg(long)]\n        long: bool,\n\n        /// Show parseable output (pnpm only)\n        #[arg(long)]\n        parseable: bool,\n\n        /// Check recursively across all workspaces\n        #[arg(short = 'r', long)]\n        recursive: bool,\n\n        /// Filter packages in monorepo (pnpm only)\n        #[arg(long, value_name = \"PATTERN\")]\n        filter: Vec<String>,\n\n        /// Check in workspace root (pnpm only)\n        #[arg(short = 'w', long)]\n        workspace_root: bool,\n\n        /// Only production dependencies (pnpm only)\n        #[arg(short = 'P', long)]\n        prod: bool,\n\n        /// Only dev dependencies (pnpm only)\n        #[arg(short = 'D', long)]\n        dev: bool,\n\n        /// Limit tree depth (pnpm only)\n        #[arg(long)]\n        depth: Option<u32>,\n\n        /// Exclude optional dependencies (pnpm only)\n        #[arg(long)]\n        no_optional: bool,\n\n        /// Check globally installed packages (pnpm only)\n        #[arg(short = 'g', long)]\n        global: bool,\n\n        /// Exclude peer dependencies (pnpm only)\n        #[arg(long)]\n        exclude_peers: bool,\n\n        /// Use a finder function defined in .pnpmfile.cjs (pnpm only)\n        #[arg(long)]\n        find_by: Option<String>,\n\n        /// Arguments to pass to package manager\n        #[arg(allow_hyphen_values = true, trailing_var_arg = true)]\n        args: Vec<String>,\n    },\n}\n```\n\n#### 2. Package Manager Adapter\n\n**File**: `crates/vite_package_manager/src/commands/why.rs` (new file)\n\n```rust\nuse std::{collections::HashMap, process::ExitStatus};\n\nuse vite_error::Error;\nuse vite_path::AbsolutePath;\n\nuse crate::package_manager::{\n    PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,\n};\n\n#[derive(Debug, Default)]\npub struct WhyCommandOptions<'a> {\n    pub packages: &'a [String],\n    pub json: bool,\n    pub long: bool,\n    pub parseable: bool,\n    pub recursive: bool,\n    pub filters: Option<&'a [String]>,\n    pub workspace_root: bool,\n    pub prod: bool,\n    pub dev: bool,\n    pub depth: Option<u32>,\n    pub no_optional: bool,\n    pub global: bool,\n    pub exclude_peers: bool,\n    pub find_by: Option<&'a str>,\n    pub pass_through_args: Option<&'a [String]>,\n}\n\nimpl PackageManager {\n    /// Run the why command with the package manager.\n    #[must_use]\n    pub async fn run_why_command(\n        &self,\n        options: &WhyCommandOptions<'_>,\n        cwd: impl AsRef<AbsolutePath>,\n    ) -> Result<ExitStatus, Error> {\n        let resolve_command = self.resolve_why_command(options);\n        run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)\n            .await\n    }\n\n    /// Resolve the why command.\n    #[must_use]\n    pub fn resolve_why_command(&self, options: &WhyCommandOptions) -> ResolveCommandResult {\n        let bin_name: String;\n        let envs = HashMap::from([(\"PATH\".to_string(), format_path_env(self.get_bin_prefix()))]);\n        let mut args: Vec<String> = Vec::new();\n\n        match self.client {\n            PackageManagerType::Pnpm => {\n                bin_name = \"pnpm\".into();\n\n                // pnpm: --filter must come before command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--filter\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                args.push(\"why\".into());\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                if options.long {\n                    args.push(\"--long\".into());\n                }\n\n                if options.parseable {\n                    args.push(\"--parseable\".into());\n                }\n\n                if options.recursive {\n                    args.push(\"--recursive\".into());\n                }\n\n                if options.workspace_root {\n                    args.push(\"--workspace-root\".into());\n                }\n\n                if options.prod {\n                    args.push(\"--prod\".into());\n                }\n\n                if options.dev {\n                    args.push(\"--dev\".into());\n                }\n\n                if let Some(depth) = options.depth {\n                    args.push(\"--depth\".into());\n                    args.push(depth.to_string());\n                }\n\n                if options.no_optional {\n                    args.push(\"--no-optional\".into());\n                }\n\n                if options.global {\n                    args.push(\"--global\".into());\n                }\n\n                if options.exclude_peers {\n                    args.push(\"--exclude-peers\".into());\n                }\n\n                if let Some(find_by) = options.find_by {\n                    args.push(\"--find-by\".into());\n                    args.push(find_by.to_string());\n                }\n\n                // Add packages (pnpm supports multiple packages)\n                args.extend_from_slice(options.packages);\n            }\n            PackageManagerType::Yarn => {\n                bin_name = \"yarn\".into();\n\n                args.push(\"why\".into());\n\n                // yarn only supports single package\n                if options.packages.len() > 1 {\n                    eprintln!(\"Warning: yarn only supports checking one package at a time, using first package\");\n                }\n                args.push(options.packages[0].clone());\n\n                // yarn@2+ supports --recursive\n                if options.recursive && !self.version.starts_with(\"1.\") {\n                    args.push(\"--recursive\".into());\n                }\n\n                // yarn@2+: Add --peers by default unless --exclude-peers is set\n                if !self.version.starts_with(\"1.\") && !options.exclude_peers {\n                    args.push(\"--peers\".into());\n                }\n\n                // Warn about unsupported flags\n                if options.json {\n                    eprintln!(\"Warning: --json not supported by yarn\");\n                }\n                if options.long {\n                    eprintln!(\"Warning: --long not supported by yarn\");\n                }\n                if options.parseable {\n                    eprintln!(\"Warning: --parseable not supported by yarn\");\n                }\n                if let Some(filters) = options.filters {\n                    if !filters.is_empty() {\n                        eprintln!(\"Warning: --filter not supported by yarn\");\n                    }\n                }\n                if options.prod || options.dev {\n                    eprintln!(\"Warning: --prod/--dev not supported by yarn\");\n                }\n                if options.find_by.is_some() {\n                    eprintln!(\"Warning: --find-by not supported by yarn\");\n                }\n            }\n            PackageManagerType::Npm => {\n                bin_name = \"npm\".into();\n\n                // npm uses 'explain' as primary command\n                args.push(\"explain\".into());\n\n                // npm: --workspace comes after command\n                if let Some(filters) = options.filters {\n                    for filter in filters {\n                        args.push(\"--workspace\".into());\n                        args.push(filter.clone());\n                    }\n                }\n\n                if options.json {\n                    args.push(\"--json\".into());\n                }\n\n                // Add packages (npm supports multiple packages)\n                args.extend_from_slice(options.packages);\n\n                // Warn about pnpm-specific flags\n                if options.long {\n                    eprintln!(\"Warning: --long not supported by npm\");\n                }\n                if options.parseable {\n                    eprintln!(\"Warning: --parseable not supported by npm\");\n                }\n                if options.prod || options.dev {\n                    eprintln!(\"Warning: --prod/--dev not supported by npm\");\n                }\n                if options.depth.is_some() {\n                    eprintln!(\"Warning: --depth not supported by npm\");\n                }\n                if options.find_by.is_some() {\n                    eprintln!(\"Warning: --find-by not supported by npm\");\n                }\n            }\n        }\n\n        // Add pass-through args\n        if let Some(pass_through_args) = options.pass_through_args {\n            args.extend_from_slice(pass_through_args);\n        }\n\n        ResolveCommandResult { bin_path: bin_name, args, envs }\n    }\n}\n```\n\n**File**: `crates/vite_package_manager/src/commands/mod.rs`\n\nUpdate to include why module:\n\n```rust\npub mod add;\nmod install;\npub mod remove;\npub mod update;\npub mod link;\npub mod unlink;\npub mod dedupe;\npub mod why;  // Add this line\n```\n\n#### 3. Why Command Implementation\n\n**File**: `crates/vite_task/src/why.rs` (new file)\n\n```rust\nuse vite_error::Error;\nuse vite_path::AbsolutePathBuf;\nuse vite_package_manager::{\n    PackageManager,\n    commands::why::WhyCommandOptions,\n};\nuse vite_workspace::Workspace;\n\npub struct WhyCommand {\n    workspace_root: AbsolutePathBuf,\n}\n\nimpl WhyCommand {\n    pub fn new(workspace_root: AbsolutePathBuf) -> Self {\n        Self { workspace_root }\n    }\n\n    pub async fn execute(\n        self,\n        packages: Vec<String>,\n        json: bool,\n        long: bool,\n        parseable: bool,\n        recursive: bool,\n        filters: Vec<String>,\n        workspace_root: bool,\n        prod: bool,\n        dev: bool,\n        depth: Option<u32>,\n        no_optional: bool,\n        global: bool,\n        exclude_peers: bool,\n        extra_args: Vec<String>,\n    ) -> Result<ExecutionSummary, Error> {\n        if packages.is_empty() {\n            return Err(Error::NoPackagesSpecified);\n        }\n\n        let package_manager = PackageManager::builder(&self.workspace_root).build().await?;\n        let workspace = Workspace::partial_load(self.workspace_root)?;\n\n        // Build why command options\n        let why_options = WhyCommandOptions {\n            packages: &packages,\n            json,\n            long,\n            parseable,\n            recursive,\n            filters: if filters.is_empty() { None } else { Some(&filters) },\n            workspace_root,\n            prod,\n            dev,\n            depth,\n            no_optional,\n            global,\n            exclude_peers,\n            pass_through_args: if extra_args.is_empty() { None } else { Some(&extra_args) },\n        };\n\n        let exit_status = package_manager\n            .run_why_command(&why_options, &workspace.root)\n            .await?;\n\n        if !exit_status.success() {\n            return Err(Error::CommandFailed {\n                command: \"why\".to_string(),\n                exit_code: exit_status.code(),\n            });\n        }\n\n        workspace.unload().await?;\n\n        Ok(ExecutionSummary::default())\n    }\n}\n```\n\n## Design Decisions\n\n### 1. No Caching\n\n**Decision**: Do not cache why operations.\n\n**Rationale**:\n\n- `why` queries current dependency state\n- Results depend on installed packages\n- Caching would provide stale information\n- Fast operation, caching not needed\n\n### 2. Multiple Package Support\n\n**Decision**: Accept multiple packages and pass them through to package managers that support it.\n\n**Rationale**:\n\n- pnpm supports multiple packages: `pnpm why react react-dom`\n- npm supports multiple packages: `npm explain react react-dom`\n- yarn only supports single package\n- Warn and use first package for yarn only\n- Better UX than erroring\n\n### 3. Alias Choice\n\n**Decision**: Use `explain` as alias (matches npm).\n\n**Rationale**:\n\n- npm uses `explain` as primary command, `why` as alias\n- More descriptive verb\n- Helps npm users feel at home\n- Both commands achieve same goal\n\n### 4. Output Format Support\n\n**Decision**: Support pnpm's output format flags with warnings on other package managers.\n\n**Rationale**:\n\n- pnpm has `--json`, `--long`, `--parseable`\n- npm only has `--json`\n- yarn has fixed output format\n- Warn users about unsupported formats\n\n### 5. Workspace Filtering\n\n**Decision**: Support `--filter` flag which translates to appropriate package manager syntax.\n\n**Rationale**:\n\n- pnpm uses `--filter` before command: `pnpm --filter app why react`\n- npm uses `--workspace` after command: `npm explain --workspace app react`\n- Vite+ uses unified `--filter` flag that translates appropriately\n- yarn doesn't support workspace filtering\n- Consistent with other Vite+ commands\n\n### 6. Dependency Type Filtering\n\n**Decision**: Support pnpm's `--prod`, `--dev`, `--no-optional` flags with warnings.\n\n**Rationale**:\n\n- pnpm allows filtering by dependency type\n- Not available in npm or yarn\n- Useful for focused analysis\n- Warn when not supported\n\n## Error Handling\n\n### No Package Manager Detected\n\n```bash\n$ vp why react\nError: No package manager detected\nPlease run one of:\n  - vp install (to set up package manager)\n  - Add packageManager field to package.json\n```\n\n### No Packages Specified\n\n```bash\n$ vp why\nerror: the following required arguments were not provided:\n  <PACKAGES>...\n\nUsage: vp why [OPTIONS] <PACKAGES>... [-- <PASS_THROUGH_ARGS>...]\n\nFor more information, try '--help'.\n```\n\n### Package Not Found\n\n```bash\n$ vp why nonexistent-package\nPackage 'nonexistent-package' is not in the project.\nExit code: 1\n```\n\n### Unsupported Flag Warning\n\n```bash\n$ vp why react --long\nWarning: --long not supported by npm\nRunning: npm explain react\n```\n\n## User Experience\n\n### Success Output (pnpm)\n\n```bash\n$ vp why react\nDetected package manager: pnpm@10.15.0\nRunning: pnpm why react\n\nLegend: production dependency, optional only, dev only\n\nmy-app@1.0.0 /Users/user/my-app\n\ndependencies:\nreact 18.3.1\n├── react-dom 18.3.1\n└─┬ @testing-library/react 14.0.0\n  └─┬ @testing-library/dom 9.3.4\n    └─┬ @testing-library/user-event 14.5.2\n      └── react-dom 18.3.1\n\ndevDependencies:\nreact 18.3.1\n└── @types/react 18.3.3\n\nDone in 0.5s\n```\n\n### Success Output (npm)\n\n```bash\n$ vp explain react\nDetected package manager: npm@11.0.0\nRunning: npm explain react\n\nreact@18.3.1\nnode_modules/react\n  react@\"^18.3.1\" from react-dom@18.3.1\n  node_modules/react-dom\n    react-dom@\"^18.3.1\" from the root project\n  react@\"^18.3.1\" from @testing-library/react@14.0.0\n  node_modules/@testing-library/react\n    @testing-library/react@\"^14.0.0\" from the root project\n\nDone in 0.3s\n```\n\n### Success Output (yarn)\n\n```bash\n$ vp why react\nDetected package manager: yarn@1.22.19\nRunning: yarn why react\n\n[1/4] 🤔  Why do we have the package \"react\"?\n[2/4] 🚚  Required dependencies\ninfo Reasons this module exists\n   - \"my-app#dependencies\" depends on it\n   - Hoisted from \"my-app#react\"\n[3/4] 💾  Disk size without dependencies: \"285KB\"\n[4/4] 📦  Dependencies using this package: react-dom, @testing-library/react\n\nDone in 0.8s\n```\n\n### JSON Output (pnpm)\n\n```bash\n$ vp why react --json\nDetected package manager: pnpm@10.15.0\nRunning: pnpm why react --json\n\n[\n  {\n    \"name\": \"my-app\",\n    \"version\": \"1.0.0\",\n    \"path\": \"/Users/user/my-app\",\n    \"dependencies\": {\n      \"react\": {\n        \"version\": \"18.3.1\",\n        \"dependents\": [\n          {\n            \"name\": \"react-dom\",\n            \"version\": \"18.3.1\"\n          },\n          {\n            \"name\": \"@testing-library/react\",\n            \"version\": \"14.0.0\"\n          }\n        ]\n      }\n    }\n  }\n]\n\nDone in 0.4s\n```\n\n### Multiple Packages (pnpm)\n\n```bash\n$ vp why react react-dom lodash\nDetected package manager: pnpm@10.15.0\nRunning: pnpm why react react-dom lodash\n\nLegend: production dependency, optional only, dev only\n\nmy-app@1.0.0 /Users/user/my-app\n\nreact 18.3.1\n└── react-dom 18.3.1\n\nreact-dom 18.3.1\ndependency of my-app\n\nlodash 4.17.21\n└─┬ webpack 5.95.0\n  └── babel-loader 9.2.1\n\nDone in 0.6s\n```\n\n## Alternative Designs Considered\n\n### Alternative 1: Separate Command Names\n\n```bash\nvp why <package>      # For pnpm/yarn\nvp explain <package>  # For npm only\n```\n\n**Rejected because**:\n\n- Creates confusion about which to use\n- Package manager should be abstracted\n- Aliases are better than separate commands\n\n### Alternative 2: Always Use Multiple Package Format\n\n```bash\nvp why react react-dom  # Always accept multiple\n# Error on npm/yarn\n```\n\n**Rejected because**:\n\n- Too strict, prevents usage\n- Better to warn and use first package\n- Provides better UX\n\n### Alternative 3: Auto-Translate Output Format\n\n```bash\nvp why react --json  # On yarn\n# Attempt to convert yarn's output to JSON\n```\n\n**Rejected because**:\n\n- Output format parsing is fragile\n- Different package managers have different data\n- Better to warn about unsupported features\n- Let native output through\n\n## Implementation Plan\n\n### Phase 1: Core Functionality\n\n1. Add `Why` command variant to `Commands` enum\n2. Create `why.rs` module in both crates\n3. Implement package manager command resolution\n4. Add basic error handling\n\n### Phase 2: Advanced Features\n\n1. Implement output format options (json, long, parseable)\n2. Add workspace filtering support\n3. Implement dependency type filtering (prod, dev)\n4. Handle depth limiting\n\n### Phase 3: Testing\n\n1. Unit tests for command resolution\n2. Integration tests with mock package managers\n3. Test multiple package support\n4. Test workspace operations\n5. Test output format options\n\n### Phase 4: Documentation\n\n1. Update CLI documentation\n2. Add examples to README\n3. Document package manager compatibility\n4. Add troubleshooting guide\n\n## Testing Strategy\n\n### Test Package Manager Versions\n\n- pnpm@9.x\n- pnpm@10.x\n- yarn@1.x\n- yarn@4.x\n- npm@10.x\n- npm@11.x\n\n### Unit Tests\n\n```rust\n#[test]\nfn test_pnpm_why_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string()],\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"why\", \"react\"]);\n}\n\n#[test]\nfn test_pnpm_why_multiple_packages() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string(), \"lodash\".to_string()],\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"why\", \"react\", \"lodash\"]);\n}\n\n#[test]\nfn test_pnpm_why_json() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string()],\n        json: true,\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"why\", \"--json\", \"react\"]);\n}\n\n#[test]\nfn test_npm_explain_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Npm);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string()],\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"explain\", \"react\"]);\n}\n\n#[test]\nfn test_yarn_why_basic() {\n    let pm = PackageManager::mock(PackageManagerType::Yarn);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string()],\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"why\", \"react\"]);\n}\n\n#[test]\nfn test_pnpm_why_with_filter() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string()],\n        filters: Some(&[\"app\".to_string()]),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"--filter\", \"app\", \"why\", \"react\"]);\n}\n\n#[test]\nfn test_pnpm_why_with_depth() {\n    let pm = PackageManager::mock(PackageManagerType::Pnpm);\n    let args = pm.resolve_why_command(&WhyCommandOptions {\n        packages: &[\"react\".to_string()],\n        depth: Some(3),\n        ..Default::default()\n    });\n    assert_eq!(args, vec![\"why\", \"--depth\", \"3\", \"react\"]);\n}\n```\n\n### Integration Tests\n\nCreate fixtures for testing with each package manager:\n\n```\nfixtures/why-test/\n  pnpm-workspace.yaml\n  package.json\n  packages/\n    app/\n      package.json (with react, lodash deps)\n    utils/\n      package.json (with lodash dep)\n  test-steps.json\n```\n\nTest cases:\n\n1. Basic why for single package\n2. Multiple packages (pnpm only)\n3. JSON output\n4. Workspace-specific why\n5. Recursive workspace checking\n6. Dependency type filtering\n7. Depth limiting\n8. Global package checking\n9. Warning messages for unsupported flags\n\n## CLI Help Output\n\n```bash\n$ vp why --help\nShow why a package is installed\n\nUsage: vp why [OPTIONS] <PACKAGE>... [-- <PASS_THROUGH_ARGS>...]\n\nAliases: explain\n\nArguments:\n  <PACKAGE>...           Package(s) to check (required, pnpm/npm support multiple, yarn uses first)\n\nOptions:\n  --json                 Output in JSON format\n  --long                 Show extended information (pnpm-specific)\n  --parseable            Show parseable output (pnpm-specific)\n  -r, --recursive        Check recursively across all workspaces\n  --filter <PATTERN>     Filter packages in monorepo (pnpm-specific, can be used multiple times)\n  -w, --workspace-root   Check in workspace root (pnpm-specific)\n  -P, --prod             Only production dependencies (pnpm-specific)\n  -D, --dev              Only dev dependencies (pnpm-specific)\n  --depth <NUMBER>       Limit tree depth (pnpm-specific)\n  --no-optional          Exclude optional dependencies (pnpm-specific)\n  -g, --global           Check globally installed packages\n  --exclude-peers        Exclude peer dependencies (pnpm/yarn@2+-specific)\n  --find-by <FINDER_NAME> Use a finder function defined in .pnpmfile.cjs (pnpm-specific)\n  -h, --help             Print help\n\nPackage Manager Behavior:\n  pnpm:    Shows complete dependency tree with all dependents\n  npm:     Shows dependency path explaining installation\n  yarn@1:  Shows why package exists with disk size info\n  yarn@2+: Shows dependency tree in streamlined format\n\nExamples:\n  vp why react                       # Show why react is installed\n  vp explain lodash                  # Same as above (alias)\n  vp why react react-dom             # Check multiple packages (pnpm/npm)\n  vp why react --json                # JSON output\n  vp why react --long                # Verbose output (pnpm)\n  vp why react -r                    # Recursive across workspaces\n  vp why react --filter app          # Check in specific workspace (pnpm)\n  vp why react --prod                # Only production deps (pnpm)\n  vp why react --depth 3             # Limit tree depth (pnpm)\n  vp why typescript -g               # Check global packages\n  vp why react --find-by myFinder    # Use custom finder (pnpm)\n```\n\n## Performance Considerations\n\n1. **No Caching**: Fast query operation, caching not beneficial\n2. **Native Performance**: Delegates to package manager's optimized code\n3. **Single Execution**: Quick analysis of current state\n4. **JSON Output**: Can be parsed for programmatic usage\n\n## Security Considerations\n\n1. **Read-Only**: Only reads installed packages, no modifications\n2. **No Code Execution**: Just queries dependency tree\n3. **Safe for CI**: Can be run safely in CI/CD pipelines\n4. **Audit Integration**: Helps understand security vulnerability origins\n\n## Backward Compatibility\n\nThis is a new feature with no breaking changes:\n\n- Existing commands unaffected\n- New command is additive\n- No changes to task configuration\n- No changes to caching behavior\n\n## Migration Path\n\n### Adoption\n\nUsers can start using immediately:\n\n```bash\n# Old way\npnpm why react\nnpm explain react\n\n# New way (works with any package manager)\nvp why react\nvp explain react\n```\n\n### CI/CD Integration\n\n```yaml\n# Check why specific package is installed\n- run: vp why lodash --json > why-lodash.json\n\n# Verify expected dependency paths\n- run: vp why react | grep \"react-dom\"\n```\n\n## Real-World Usage Examples\n\n### Debugging Duplicate Dependencies\n\n```bash\n# Check why multiple versions are installed\nvp why lodash\nvp why lodash --json | jq '.[] | .dependencies.lodash.version'\n\n# Check across workspaces\nvp why lodash -r\n```\n\n### Understanding Transitive Dependencies\n\n```bash\n# Why is this indirect dependency here?\nvp why core-js\nvp why core-js --long\n\n# What's using this deep dependency?\nvp why @babel/helper-plugin-utils\n```\n\n### Auditing Dependencies\n\n```bash\n# Check security vulnerability origins\nvp why vulnerable-package\nvp why vulnerable-package --prod  # Only production\n\n# Find all dependents in monorepo\nvp why legacy-library -r --json\n```\n\n### Workspace Analysis\n\n```bash\n# Which workspaces use this package?\nvp why react -r\n\n# Check specific workspace\nvp why lodash --filter utils\n\n# Compare dependency reasons across workspaces\nvp why axios --filter \"app*\" -r\n```\n\n### Production Dependency Analysis\n\n```bash\n# What production code needs this?\nvp why package --prod\n\n# Exclude dev dependencies\nvp why package --prod --json\n```\n\n## Package Manager Compatibility\n\n| Feature          | pnpm              | npm              | yarn@1           | yarn@2+          | Notes                   |\n| ---------------- | ----------------- | ---------------- | ---------------- | ---------------- | ----------------------- |\n| Basic command    | `why`             | `explain`        | `why`            | `why`            | npm uses different name |\n| Multiple pkgs    | ✅ Supported      | ✅ Supported     | ❌ Single only   | ❌ Single only   | pnpm and npm            |\n| Glob patterns    | ✅ Supported      | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only               |\n| JSON output      | ✅ `--json`       | ✅ `--json`      | ❌ Not supported | ❌ Not supported | pnpm and npm only       |\n| Long output      | ✅ `--long`       | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only               |\n| Parseable        | ✅ `--parseable`  | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only               |\n| Recursive        | ✅ `-r`           | ❌ Not supported | ❌ Not supported | ✅ `--recursive` | pnpm and yarn@2+        |\n| Workspace filter | ✅ `--filter`     | ✅ `--workspace` | ❌ Not supported | ❌ Not supported | pnpm and npm            |\n| Dep type filter  | ✅ `--prod/--dev` | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only               |\n| Depth limit      | ✅ `--depth`      | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only               |\n| Global check     | ✅ `-g`           | ❌ Not supported | ❌ Not supported | ❌ Not supported | pnpm only               |\n\n## Future Enhancements\n\n### 1. Dependency Graph Visualization\n\nGenerate visual dependency graphs:\n\n```bash\nvp why react --graph > dep-graph.html\n\n# ASCII tree visualization\nvp why react --tree\n```\n\n### 2. Compare Why Across Versions\n\nShow how dependency changed:\n\n```bash\nvp why lodash --compare-version 4.17.20\n\n# Output:\nlodash@4.17.21 (was 4.17.20)\n└── webpack 5.95.0 (upgraded from 5.90.0)\n```\n\n### 3. Why Report\n\nGenerate comprehensive dependency report:\n\n```bash\nvp why --report-all > dependencies-report.json\n\n# All packages and their dependents\n# Useful for auditing and optimization\n```\n\n### 4. Circular Dependency Detection\n\nHighlight circular dependencies:\n\n```bash\nvp why package-a --detect-circular\n\n# Output:\n⚠️  Circular dependency detected:\npackage-a → package-b → package-c → package-a\n```\n\n### 5. Size Analysis Integration\n\nShow size impact:\n\n```bash\nvp why lodash --with-size\n\n# Output:\nlodash@4.17.21 (285KB gzipped)\n└── webpack (brings in 15MB)\n└── babel (brings in 8MB)\nTotal impact: 23.3MB\n```\n\n## Open Questions\n\n1. **Should we support package path queries (yarn style)?**\n   - Proposed: Yes, for yarn compatibility\n   - Example: `vp why node_modules/once/once.js`\n   - Translate to package name for other PMs\n\n2. **Should we aggregate output when checking multiple packages?**\n   - Proposed: No, show separate results\n   - Matches pnpm behavior\n   - Easier to parse\n\n3. **Should we support interactive mode?**\n   - Proposed: Later enhancement\n   - Let users explore dependency tree interactively\n   - Similar to `npm ls --interactive`\n\n4. **Should we cache why results?**\n   - Proposed: No, always query current state\n   - Dependency tree changes frequently\n   - Fast operation doesn't need caching\n\n5. **Should we integrate with audit?**\n   - Proposed: Later enhancement\n   - Show security info inline\n   - Example: `vp why package --with-audit`\n\n## Success Metrics\n\n1. **Adoption**: % of users using `vp why` vs direct package manager\n2. **Debugging Efficiency**: Time to identify dependency issues\n3. **CI Integration**: Usage in CI/CD for dependency validation\n4. **User Feedback**: Survey/issues about command usefulness\n\n## Conclusion\n\nThis RFC proposes adding `vp why` command to provide a unified interface for understanding dependency relationships across pnpm/npm/yarn. The design:\n\n- ✅ Automatically adapts to detected package manager\n- ✅ Supports multiple packages (pnpm) with graceful degradation\n- ✅ Full pnpm feature support (json, long, parseable, filters)\n- ✅ npm and yarn compatibility with appropriate warnings\n- ✅ Workspace-aware operations\n- ✅ Clear output showing dependency paths\n- ✅ No caching (reads current state)\n- ✅ Simple implementation leveraging existing infrastructure\n- ✅ Extensible for future enhancements (graphs, size analysis)\n\nThe implementation follows the same patterns as other package management commands while providing the dependency analysis features developers need to understand, debug, and optimize their dependency trees.\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\n# Needed nightly features:\n# - cargo `Z-bindeps` to build and embed preload shared libraries as dependencies of fspy\n# - `windows_process_extensions_main_thread_handle` to get the main thread handle for Detours injection\nchannel = \"nightly-2025-12-11\"\nprofile = \"default\"\n"
  },
  {
    "path": "scripts/generate-license.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\n\ninterface GenerateLicenseFileOptions {\n  title: string;\n  packageName: string;\n  outputPath: string;\n  coreLicensePath: string;\n  bundledPaths: string[];\n  resolveFrom?: string[];\n  extraPackages?: PackageReference[];\n  excludePackageNames?: string[];\n}\n\ninterface PackageReference {\n  packageDir: string;\n  licensePath?: string;\n}\n\ninterface DependencyInfo {\n  name: string;\n  license?: string;\n  licenseText?: string;\n  author?: string;\n  contributors: string[];\n  maintainers: string[];\n  repository?: string;\n}\n\ninterface FormattedDependencyInfo {\n  license?: string;\n  names?: string;\n  repository?: string;\n}\n\nconst LICENSE_FILE_NAMES = [\n  'LICENSE',\n  'LICENSE.md',\n  'LICENSE.txt',\n  'LICENCE',\n  'LICENCE.md',\n  'LICENCE.txt',\n  'license',\n  'license.md',\n  'license.txt',\n  'COPYING',\n] as const;\n\nconst TEXT_FILE_SUFFIXES = [\n  '.js',\n  '.mjs',\n  '.cjs',\n  '.ts',\n  '.mts',\n  '.cts',\n  '.d.ts',\n  '.d.mts',\n  '.d.cts',\n  '.css',\n  '.html',\n] as const;\n\nconst NODE_MODULES_REGION_RE = /\\/\\/#region\\s+([^\\r\\n]*node_modules[^\\r\\n]*)/g;\nconst pnpmStoreResolutionCache = new Map<string, Map<string, string | null>>();\n\nexport function generateLicenseFile(options: GenerateLicenseFileOptions) {\n  const packageRefs = new Map<string, PackageReference>();\n  const resolveFrom = options.resolveFrom ?? [process.cwd()];\n  const excludedPackageNames = new Set(options.excludePackageNames ?? []);\n\n  for (const packageName of collectBundledPackageNames(options.bundledPaths)) {\n    if (excludedPackageNames.has(packageName)) {\n      continue;\n    }\n\n    const packageDir = resolvePackageDir(packageName, resolveFrom);\n    if (!packageDir) {\n      throw new Error(`Could not resolve bundled package \"${packageName}\" for license generation`);\n    }\n\n    addPackageReference(packageRefs, { packageDir });\n  }\n\n  for (const extraPackage of options.extraPackages ?? []) {\n    const packageInfo = readPackageJson(extraPackage.packageDir);\n    if (!packageInfo) {\n      continue;\n    }\n\n    const packageName = typeof packageInfo.name === 'string' ? packageInfo.name : undefined;\n    if (packageName && excludedPackageNames.has(packageName)) {\n      continue;\n    }\n\n    addPackageReference(packageRefs, extraPackage);\n  }\n\n  const dependencies = Array.from(packageRefs.values())\n    .map((packageRef) => readDependencyInfo(packageRef))\n    .filter((dependency): dependency is DependencyInfo => dependency !== null);\n\n  const deps = sortDependencies(dependencies);\n  const licenses = sortLicenses(\n    new Set(\n      deps\n        .map((dependency) => dependency.license)\n        .filter((license): license is string => typeof license === 'string'),\n    ),\n  );\n  const coreLicense = fs.readFileSync(options.coreLicensePath, 'utf-8');\n\n  let dependencyLicenseTexts = '';\n  for (let i = 0; i < deps.length; i++) {\n    const licenseText = deps[i].licenseText;\n    const sameDeps = [deps[i]];\n    if (licenseText) {\n      for (let j = i + 1; j < deps.length; j++) {\n        if (licenseText === deps[j].licenseText) {\n          sameDeps.push(...deps.splice(j, 1));\n          j--;\n        }\n      }\n    }\n\n    let text = `## ${sameDeps.map((dependency) => dependency.name).join(', ')}\\n`;\n    const depInfos = sameDeps.map((dependency) => getDependencyInformation(dependency));\n\n    if (\n      depInfos.length > 1 &&\n      depInfos.every(\n        (info) => info.license === depInfos[0].license && info.names === depInfos[0].names,\n      )\n    ) {\n      const { license, names } = depInfos[0];\n      const repositoryText = depInfos\n        .map((info) => info.repository)\n        .filter(Boolean)\n        .join(', ');\n\n      if (license) {\n        text += `License: ${license}\\n`;\n      }\n      if (names) {\n        text += `By: ${names}\\n`;\n      }\n      if (repositoryText) {\n        text += `Repositories: ${repositoryText}\\n`;\n      }\n    } else {\n      for (let j = 0; j < depInfos.length; j++) {\n        const { license, names, repository } = depInfos[j];\n        if (license) {\n          text += `License: ${license}\\n`;\n        }\n        if (names) {\n          text += `By: ${names}\\n`;\n        }\n        if (repository) {\n          text += `Repository: ${repository}\\n`;\n        }\n        if (j !== depInfos.length - 1) {\n          text += '\\n';\n        }\n      }\n    }\n\n    if (licenseText) {\n      text +=\n        '\\n' +\n        licenseText\n          .trim()\n          .replace(/\\r\\n|\\r/g, '\\n')\n          .split('\\n')\n          .map((line) => `> ${line}`)\n          .join('\\n') +\n        '\\n';\n    }\n\n    if (i !== deps.length - 1) {\n      text += '\\n---------------------------------------\\n\\n';\n    }\n\n    dependencyLicenseTexts += text;\n  }\n\n  const licenseFileContent =\n    `# ${options.title}\\n` +\n    `${options.packageName} is released under the MIT license:\\n\\n` +\n    coreLicense +\n    `\\n` +\n    `# Licenses of bundled dependencies\\n` +\n    `The published ${options.packageName} artifact additionally contains code with the following licenses:\\n` +\n    `${licenses.join(', ')}\\n\\n` +\n    `# Bundled dependencies:\\n` +\n    dependencyLicenseTexts;\n\n  let existingContent: string | undefined;\n  try {\n    existingContent = fs.readFileSync(options.outputPath, 'utf-8');\n  } catch {\n    // File does not exist yet.\n  }\n\n  if (existingContent !== licenseFileContent) {\n    fs.writeFileSync(options.outputPath, licenseFileContent);\n    console.error('\\x1b[33m\\nLICENSE.md updated. You should commit the updated file.\\n\\x1b[0m');\n  }\n}\n\nfunction collectBundledPackageNames(bundledPaths: string[]): Set<string> {\n  const packageNames = new Set<string>();\n\n  for (const bundledPath of bundledPaths) {\n    if (!fs.existsSync(bundledPath)) {\n      continue;\n    }\n\n    for (const filePath of walkTextFiles(bundledPath)) {\n      const content = fs.readFileSync(filePath, 'utf-8');\n      for (const match of content.matchAll(NODE_MODULES_REGION_RE)) {\n        const packageName = extractPackageName(match[1]);\n        if (packageName) {\n          packageNames.add(packageName);\n        }\n      }\n    }\n  }\n\n  return packageNames;\n}\n\nfunction* walkTextFiles(targetPath: string): Generator<string> {\n  const stats = fs.statSync(targetPath);\n\n  if (stats.isFile()) {\n    if (isTextFile(targetPath)) {\n      yield targetPath;\n    }\n    return;\n  }\n\n  if (!stats.isDirectory()) {\n    return;\n  }\n\n  for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {\n    const entryPath = path.join(targetPath, entry.name);\n    if (entry.isDirectory()) {\n      yield* walkTextFiles(entryPath);\n      continue;\n    }\n\n    if (entry.isFile() && isTextFile(entryPath)) {\n      yield entryPath;\n    }\n  }\n}\n\nfunction isTextFile(filePath: string): boolean {\n  return TEXT_FILE_SUFFIXES.some((suffix) => filePath.endsWith(suffix));\n}\n\nfunction extractPackageName(regionPath: string): string | undefined {\n  const normalized = regionPath.replaceAll('\\\\', '/');\n  const nodeModulesIndex = normalized.lastIndexOf('/node_modules/');\n  if (nodeModulesIndex === -1) {\n    return undefined;\n  }\n\n  const afterNodeModules = normalized.slice(nodeModulesIndex + '/node_modules/'.length);\n  if (afterNodeModules.startsWith('@')) {\n    const parts = afterNodeModules.split('/');\n    if (parts.length < 2) {\n      return undefined;\n    }\n    return `${parts[0]}/${parts[1]}`;\n  }\n\n  const packageName = afterNodeModules.split('/')[0];\n  if (!packageName || packageName.startsWith('.')) {\n    return undefined;\n  }\n  return packageName;\n}\n\nfunction resolvePackageDir(packageName: string, resolveFrom: string[]): string | undefined {\n  const seen = new Set<string>();\n\n  for (const startPath of resolveFrom) {\n    let currentDir = path.resolve(startPath);\n    while (true) {\n      if (seen.has(currentDir)) {\n        break;\n      }\n      seen.add(currentDir);\n\n      const packageDir = path.join(currentDir, 'node_modules', packageName);\n      const packageJsonPath = path.join(packageDir, 'package.json');\n      if (fs.existsSync(packageJsonPath)) {\n        return packageDir;\n      }\n\n      const pnpmStoreDir = path.join(currentDir, 'node_modules', '.pnpm');\n      const pnpmPackageDir = resolvePackageDirInPnpmStore(pnpmStoreDir, packageName);\n      if (pnpmPackageDir) {\n        return pnpmPackageDir;\n      }\n\n      const parentDir = path.dirname(currentDir);\n      if (parentDir === currentDir) {\n        break;\n      }\n      currentDir = parentDir;\n    }\n  }\n\n  return undefined;\n}\n\nfunction resolvePackageDirInPnpmStore(\n  pnpmStoreDir: string,\n  packageName: string,\n): string | undefined {\n  if (!fs.existsSync(pnpmStoreDir)) {\n    return undefined;\n  }\n\n  let storeCache = pnpmStoreResolutionCache.get(pnpmStoreDir);\n  if (!storeCache) {\n    storeCache = new Map<string, string | null>();\n    pnpmStoreResolutionCache.set(pnpmStoreDir, storeCache);\n  }\n\n  const cachedPackageDir = storeCache.get(packageName);\n  if (cachedPackageDir !== undefined) {\n    return cachedPackageDir ?? undefined;\n  }\n\n  for (const entry of fs.readdirSync(pnpmStoreDir, { withFileTypes: true })) {\n    if (!entry.isDirectory()) {\n      continue;\n    }\n\n    const packageDir = path.join(pnpmStoreDir, entry.name, 'node_modules', packageName);\n    const packageJsonPath = path.join(packageDir, 'package.json');\n    if (fs.existsSync(packageJsonPath)) {\n      storeCache.set(packageName, packageDir);\n      return packageDir;\n    }\n  }\n\n  storeCache.set(packageName, null);\n  return undefined;\n}\n\nfunction addPackageReference(\n  packageRefs: Map<string, PackageReference>,\n  packageRef: PackageReference,\n) {\n  const packageJsonPath = path.join(packageRef.packageDir, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return;\n  }\n\n  const normalizedDir = fs.realpathSync(packageRef.packageDir);\n  const existing = packageRefs.get(normalizedDir);\n  if (!existing) {\n    packageRefs.set(normalizedDir, packageRef);\n    return;\n  }\n\n  if (!existing.licensePath && packageRef.licensePath) {\n    packageRefs.set(normalizedDir, packageRef);\n  }\n}\n\nfunction readDependencyInfo(packageRef: PackageReference): DependencyInfo | null {\n  const pkgJson = readPackageJson(packageRef.packageDir);\n  if (!pkgJson) {\n    return null;\n  }\n\n  const name =\n    typeof pkgJson.name === 'string' ? pkgJson.name : path.basename(packageRef.packageDir);\n  const dependency: DependencyInfo = {\n    name,\n    license: typeof pkgJson.license === 'string' ? pkgJson.license : undefined,\n    contributors: [],\n    maintainers: [],\n  };\n\n  if (pkgJson.author) {\n    dependency.author =\n      typeof pkgJson.author === 'string'\n        ? pkgJson.author\n        : (pkgJson.author as Record<string, string>).name;\n  }\n\n  if (Array.isArray(pkgJson.contributors)) {\n    for (const contributor of pkgJson.contributors) {\n      const name = typeof contributor === 'string' ? contributor : contributor?.name;\n      if (name) {\n        dependency.contributors.push(name);\n      }\n    }\n  }\n\n  if (Array.isArray(pkgJson.maintainers)) {\n    for (const maintainer of pkgJson.maintainers) {\n      const name = typeof maintainer === 'string' ? maintainer : maintainer?.name;\n      if (name) {\n        dependency.maintainers.push(name);\n      }\n    }\n  }\n\n  if (pkgJson.repository) {\n    const repositoryUrl =\n      typeof pkgJson.repository === 'string'\n        ? pkgJson.repository\n        : (pkgJson.repository as Record<string, string>).url;\n    if (repositoryUrl) {\n      dependency.repository = normalizeGitUrl(repositoryUrl);\n    }\n  }\n\n  dependency.licenseText = readLicenseText(packageRef);\n  return dependency;\n}\n\nfunction readPackageJson(packageDir: string): Record<string, unknown> | null {\n  const packageJsonPath = path.join(packageDir, 'package.json');\n  if (!fs.existsSync(packageJsonPath)) {\n    return null;\n  }\n\n  try {\n    return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));\n  } catch {\n    return null;\n  }\n}\n\nfunction readLicenseText(packageRef: PackageReference): string | undefined {\n  if (packageRef.licensePath && fs.existsSync(packageRef.licensePath)) {\n    return fs.readFileSync(packageRef.licensePath, 'utf-8');\n  }\n\n  for (const licenseFileName of LICENSE_FILE_NAMES) {\n    const licensePath = path.join(packageRef.packageDir, licenseFileName);\n    if (fs.existsSync(licensePath)) {\n      return fs.readFileSync(licensePath, 'utf-8');\n    }\n  }\n\n  return undefined;\n}\n\nfunction sortDependencies(dependencies: DependencyInfo[]): DependencyInfo[] {\n  return dependencies.toSorted((a, b) => a.name.localeCompare(b.name));\n}\n\nfunction sortLicenses(licenses: Set<string>): string[] {\n  const withParenthesis: string[] = [];\n  const withoutParenthesis: string[] = [];\n\n  for (const license of licenses) {\n    if (license.startsWith('(')) {\n      withParenthesis.push(license);\n    } else {\n      withoutParenthesis.push(license);\n    }\n  }\n\n  withParenthesis.sort();\n  withoutParenthesis.sort();\n\n  return [...withoutParenthesis, ...withParenthesis];\n}\n\nfunction getDependencyInformation(dependency: DependencyInfo): FormattedDependencyInfo {\n  const info: FormattedDependencyInfo = {};\n\n  if (dependency.license) {\n    info.license = dependency.license;\n  }\n\n  const names = new Set<string>();\n  if (dependency.author) {\n    names.add(dependency.author);\n  }\n  for (const name of dependency.contributors) {\n    names.add(name);\n  }\n  for (const name of dependency.maintainers) {\n    names.add(name);\n  }\n\n  if (names.size > 0) {\n    info.names = Array.from(names).join(', ');\n  }\n\n  if (dependency.repository) {\n    info.repository = dependency.repository;\n  }\n\n  return info;\n}\n\nfunction normalizeGitUrl(url: string): string {\n  url = url\n    .replace(/^git\\+/, '')\n    .replace(/\\.git$/, '')\n    .replace(/(^|\\/)[^/]+?@/, '$1')\n    .replace(/(\\.[^.]+?):/, '$1/')\n    .replace(/^git:\\/\\//, 'https://')\n    .replace(/^ssh:\\/\\//, 'https://');\n\n  if (url.startsWith('github:')) {\n    return `https://github.com/${url.slice(7)}`;\n  }\n  if (url.startsWith('gitlab:')) {\n    return `https://gitlab.com/${url.slice(7)}`;\n  }\n  if (url.startsWith('bitbucket:')) {\n    return `https://bitbucket.org/${url.slice(10)}`;\n  }\n  if (!url.includes(':') && url.split('/').length === 2) {\n    return `https://github.com/${url}`;\n  }\n  return url.includes('://') ? url : `https://${url}`;\n}\n"
  },
  {
    "path": "tmp/.gitignore",
    "content": "*.tgz"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"allowImportingTsExtensions\": true,\n    \"declaration\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"preserve\",\n    \"moduleDetection\": \"force\",\n    \"moduleResolution\": \"bundler\",\n    \"noEmit\": true,\n    \"noUnusedLocals\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"target\": \"esnext\",\n    \"types\": [\"node\"],\n    \"verbatimModuleSyntax\": true\n  },\n  \"exclude\": [\n    \"ecosystem-ci\",\n    \"packages/cli/snap-tests\",\n    \"packages/cli/snap-tests-global\",\n    \"vite\",\n    \"rolldown\",\n    \"target\"\n  ]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n  lint: {\n    options: {\n      typeAware: true,\n      typeCheck: true,\n    },\n    plugins: ['unicorn', 'typescript', 'oxc'],\n    categories: {\n      correctness: 'error',\n      perf: 'error',\n      suspicious: 'error',\n    },\n    rules: {\n      'eslint/no-await-in-loop': 'off',\n      'no-console': ['error', { allow: ['error'] }],\n      'no-shadow': 'off',\n      'typescript/no-unnecessary-boolean-literal-compare': 'off',\n      'typescript/no-unsafe-type-assertion': 'off',\n      curly: 'error',\n    },\n    overrides: [\n      {\n        files: [\n          '.github/**/*',\n          'bench/**/*.ts',\n          'ecosystem-ci/**/*',\n          'packages/*/build.ts',\n          'packages/core/rollupLicensePlugin.ts',\n          'packages/core/vite-rolldown.config.ts',\n          'packages/tools/**/*.ts',\n        ],\n        rules: {\n          'no-console': 'off',\n        },\n      },\n      {\n        files: ['packages/cli/src/__tests__/index.spec.ts'],\n        rules: {\n          'typescript/await-thenable': 'off',\n        },\n      },\n    ],\n    ignorePatterns: [\n      '**/snap-tests/**',\n      '**/snap-tests-global/**',\n      '**/snap-tests-todo/**',\n      'docs/**',\n      'packages/*/binding/**',\n      'packages/core/rollupLicensePlugin.ts',\n      'packages/core/vite-rolldown.config.ts',\n    ],\n  },\n  test: {\n    exclude: [\n      './ecosystem-ci/**',\n      './vite/**',\n      './rolldown/**',\n      '**/node_modules/**',\n      '**/snap-tests/**',\n      // FIXME: Error: failed to prepare the command for injection: Invalid argument (os error 22)\n      'packages/*/binding/__tests__/',\n    ],\n  },\n  fmt: {\n    ignorePatterns: [\n      '**/tmp/**',\n      'packages/cli/snap-tests/check-*/**',\n      'packages/cli/snap-tests/fmt-ignore-patterns/src/ignored',\n      'packages/cli/snap-tests-global/migration-lint-staged-ts-config',\n      'docs/**',\n      'ecosystem-ci/*/**',\n      'packages/test/**.cjs',\n      'packages/test/**.cts',\n      'packages/test/**.d.mjs',\n      'packages/test/**.d.ts',\n      'packages/test/**.mjs',\n      'packages/test/browser/',\n      'vite',\n      'rolldown',\n    ],\n    singleQuote: true,\n    semi: true,\n    sortPackageJson: true,\n    sortImports: {\n      groups: [\n        ['type-import'],\n        ['type-builtin', 'value-builtin'],\n        ['type-external', 'value-external', 'type-internal', 'value-internal'],\n        [\n          'type-parent',\n          'type-sibling',\n          'type-index',\n          'value-parent',\n          'value-sibling',\n          'value-index',\n        ],\n        ['unknown'],\n      ],\n      newlinesBetween: true,\n      order: 'asc',\n    },\n  },\n  run: {\n    tasks: {\n      'build:src': {\n        command: [\n          'vp run @rolldown/pluginutils#build',\n          'vp run rolldown#build-binding:release',\n          'vp run rolldown#build-node',\n          'vp run vite#build-types',\n          'vp run @voidzero-dev/vite-plus-core#build',\n          'vp run @voidzero-dev/vite-plus-test#build',\n          'vp run vite-plus#build',\n        ].join(' && '),\n      },\n    },\n  },\n});\n"
  }
]