diff --git a/Cargo.lock b/Cargo.lock index ed41b934b1..60da7bd85d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8445,6 +8445,7 @@ dependencies = [ "indoc", "js-semver", "oxc_resolver", + "pathdiff", "rustc-hash", "same-file", "serde", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md index 5d80c7b545..358c5766f1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md @@ -9,6 +9,8 @@ declining the overwrite prompt preserves the existing package-manager pin VITE+ - The Unified Toolchain for the Web warn: Current environment resolves to pnpm from packageManager, but yarn was requested. + File: package.json + Field: packageManager Package manager already pinned to pnpm@10.18.0 Overwrite with yarn@4.12.0? (Y/n): Cancelled. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md index e64b42fbfb..283345d4dd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md @@ -16,5 +16,7 @@ VITE+ - The Unified Toolchain for the Web warn: Current environment resolves to pnpm from lockfile or config, but yarn was requested. ✓ Pinned package manager to yarn@4.12.0 + File: package.json + Field: devEngines.packageManager note: Package manager will be downloaded on first use. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md index feae6ff454..410e9f4e42 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md @@ -9,5 +9,7 @@ VITE+ - The Unified Toolchain for the Web warn: Current environment resolves to pnpm from packageManager, but yarn was requested. ✓ Pinned package manager to yarn@4.12.0 + File: package.json + Field: packageManager note: Package manager will be downloaded on first use. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml index b12e93f5d7..7f21a2e744 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -48,3 +48,93 @@ steps = [ { argv = ["vp", "env", "pin", "pnpm@11.0.0", "--target", "dev-engines", "--no-install", "--force"], comment = "pinning a shadowed same-family devEngines version explains which declaration remains effective" }, { argv = ["vpt", "print-file", "package.json"], comment = "both explicitly targeted declarations remain unchanged except for the new devEngines pin" }, ] + +[[case]] +name = "env_unpin_package_manager_from_nested_workspace" +vp = "global" +seed-runtime = false +cwd = "packages/app" +steps = [ + { argv = ["vp", "env", "unpin", "pm"] }, + { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "remove the root manager and preserve the member manifest and sibling manager" }, +] + +[[case]] +name = "env_pin_mixed_components_from_nested_workspace" +vp = "global" +seed-runtime = false +cwd = "packages/app" +steps = [ + { argv = ["vp", "env", "pin", "22.13.0", "yarn@4.12.0", "--no-install", "--force"] }, + { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "Node is local while the package manager belongs to the workspace" }, + { argv = ["vp", "env", "pin", "--unpin"] }, + { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "mixed removal uses the same separate targets" }, +] + +[[case]] +name = "env_unpin_scoped_manager_falls_back_to_dev_engines" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"packageManager\": \"pnpm@10.18.0\", \"devEngines\": {\"packageManager\": [{\"name\": \"npm\", \"version\": \"11.0.0\"}, {\"name\": \"yarn\", \"version\": \"4.12.0\"}]}}\n"], snapshot = false }, + { argv = ["vp", "env", "unpin", "yarn"] }, + { argv = ["vpt", "print-file", "package.json"], comment = "only the matching devEngines entry was removed" }, + { argv = ["vp", "env", "unpin", "yarn"], comment = "a miss reports both searched fields and the explicit scope" }, + { argv = ["vp", "env", "unpin", "yarn", "--target", "package-manager"], comment = "an explicit top-level target checks only that field" }, +] + +[[case]] +name = "env_pin_top_level_no_op_and_overwrite" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"packageManager\": \"pnpm@10.18.0+sha512.keep\"}\n"], snapshot = false }, + { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install"], comment = "same-value pin preserves the integrity suffix and reports no changes" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the no-op preserves the original declaration" }, + { argv = ["vpt", "pipe-stdin", "y\n", "--", "vp", "env", "pin", "pnpm@10.19.0", "--no-install"], comment = "the target precedes confirmation and follows success" }, + { argv = ["vpt", "print-file", "package.json"] }, +] + +[[case]] +name = "env_pin_same_version_dev_engines_keeps_write_behavior" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"devEngines\": {\"packageManager\": [{\"name\": \"pnpm\", \"version\": \"10.18.0\", \"onFail\": \"error\"}, {\"name\": \"yarn\", \"version\": \"4.12.0\"}]}}\n"], snapshot = false }, + { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install"], comment = "equal versions retain the existing write path rather than reporting a no-op" }, + { argv = ["vpt", "print-file", "package.json"] }, +] + +[[case]] +name = "env_unpin_reports_empty_and_missing_manifest" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\": \"empty\"}\n"], snapshot = false }, + { argv = ["vp", "env", "unpin", "pm"] }, + { argv = ["vp", "env", "unpin", "yarn", "--target", "dev-engines"] }, + { argv = ["vp", "env", "unpin", "pm", "--target", "package-manager"] }, + { argv = ["vpt", "rm", "package.json"] }, + { argv = ["vp", "env", "unpin", "pm"], comment = "a missing file is distinct from a readable manifest with no matching pin" }, +] + +[[case]] +name = "env_pin_scope_help" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "pin", "--help"] }, + { argv = ["vp", "env", "unpin", "--help"] }, +] + +[[case]] +name = "env_pin_standalone_package_manager_target" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\": \"standalone\"}\n"], snapshot = false }, + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "first write uses this project manifest without a confirmation" }, + { argv = ["vpt", "print-file", "package.json"] }, + { argv = ["vp", "env", "unpin", "pm"] }, + { argv = ["vpt", "print-file", "package.json"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md index 004acf8cb4..dc68c091b8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md @@ -12,6 +12,8 @@ pinning a shadowed same-family devEngines version explains which declaration rem VITE+ - The Unified Toolchain for the Web ✓ Pinned package manager to pnpm@11.0.0 + File: package.json + Field: devEngines.packageManager warn: Top-level packageManager pnpm@10.18.0 remains effective; remove or update it to use devEngines.packageManager pnpm@11.0.0. note: Package manager will be downloaded on first use. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md index ba3655a086..517a809b46 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md @@ -9,6 +9,8 @@ VITE+ - The Unified Toolchain for the Web warn: Current environment resolves to npm from devEngines.packageManager, but pnpm was requested. ✓ Pinned package manager to pnpm@10.18.0 + File: package.json + Field: devEngines.packageManager note: Package manager will be downloaded on first use. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_mixed_components_from_nested_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_mixed_components_from_nested_workspace.md new file mode 100644 index 0000000000..5c0c99ca93 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_mixed_components_from_nested_workspace.md @@ -0,0 +1,103 @@ +# env_pin_mixed_components_from_nested_workspace + +## `vp env pin 22.13.0 yarn@4.12.0 --no-install --force` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned Node.js version to 22.13.0 + Updated devEngines.runtime in /packages/app/package.json +note: Version will be downloaded on first use. +warn: Current environment resolves to npm from devEngines.packageManager, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 + File: ../../package.json + Field: devEngines.packageManager +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file ../../package.json package.json` + +Node is local while the package manager belongs to the workspace + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "yarn", + "version": "", + "onFail": "download" + }, + { + "name": "npm", + "version": "", + "onFail": "error" + }, + { + "name": "pnpm", + "version": "", + "onFail": "download" + } + ] + } +} +{ + "name": "app", + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vp env pin --unpin` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed devEngines.runtime node entry from /packages/app/package.json +✓ Removed package-manager pin + File: ../../package.json + Field: devEngines.packageManager +``` + +## `vpt print-file ../../package.json package.json` + +mixed removal uses the same separate targets + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "npm", + "version": "", + "onFail": "error" + }, + { + "name": "pnpm", + "version": "", + "onFail": "download" + } + ] + } +} +{ + "name": "app", + "private": true +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md index b5c1d47dc1..ebcbaf4d8e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md @@ -9,6 +9,8 @@ VITE+ - The Unified Toolchain for the Web warn: Current environment resolves to npm from devEngines.packageManager, but yarn was requested. ✓ Pinned package manager to yarn@4.12.0 + File: ../../package.json + Field: devEngines.packageManager note: Package manager will be downloaded on first use. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_same_version_dev_engines_keeps_write_behavior.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_same_version_dev_engines_keeps_write_behavior.md new file mode 100644 index 0000000000..f7f76203d3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_same_version_dev_engines_keeps_write_behavior.md @@ -0,0 +1,38 @@ +# env_pin_same_version_dev_engines_keeps_write_behavior + +## `vpt write-file package.json '{"devEngines": {"packageManager": [{"name": "pnpm", "version": "10.18.0", "onFail": "error"}, {"name": "yarn", "version": "4.12.0"}]}} +'` + + +## `vp env pin pnpm@10.18.0 --no-install` + +equal versions retain the existing write path rather than reporting a no-op + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned package manager to pnpm@10.18.0 + File: package.json + Field: devEngines.packageManager +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file package.json` + +``` +{ + "devEngines": { + "packageManager": [ + { + "name": "pnpm", + "version": "", + "onFail": "error" + }, + { + "name": "yarn", + "version": "" + } + ] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_scope_help.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_scope_help.md new file mode 100644 index 0000000000..0129992866 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_scope_help.md @@ -0,0 +1,73 @@ +# env_pin_scope_help + +## `vp env pin --help` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp env pin [OPTIONS] [SPECS]... + +Pin Node.js locally and package-manager versions at the workspace root + +Arguments: + [SPECS]... Versions to pin. Bare versions select Node.js; package managers use name@version + +Options: + --unpin Remove Node.js pins locally and package-manager pins at the workspace root + --no-install Skip pre-downloading the pinned version + --force Overwrite an existing pin without confirmation + --target Explicitly choose the write target (overrides the default selection) + Possible values: + - node-version: Pin via the .node-version file + - nvmrc: Pin via the .nvmrc file + - dev-engines: Pin via package.json#devEngines (runtime or packageManager) + - package-manager: Pin via the top-level packageManager field + -h, --help Print help (see a summary with '-h') + +Examples: + vp env pin lts # Pin Node.js to latest LTS + vp env pin pnpm@10 # Pin the package manager + vp env pin 22 pnpm@10 # Pin both components + vp env pin --unpin # Remove both effective pins + vp env pin "^20.0.0" --force # Overwrite existing pin + vp env pin 24 --target node-version # Force the .node-version file +Node.js pins use the current directory. Package-manager pins use the workspace +root package.json, or the current project package.json outside a workspace. +Pinning both components can update two different files. --target selects a pin +source, not a project directory. +The Node.js write target follows the compatibility-first rule: an existing +.node-version keeps being updated, as does an effective .nvmrc in the current +directory. +Otherwise the pin is written to package.json#devEngines.runtime, or .node-version +when the directory has no package.json. + +Documentation: https://viteplus.dev/guide/env +``` + +## `vp env unpin --help` + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp env unpin [OPTIONS] [SCOPE] + +Remove Node.js pins locally and package-manager pins at the workspace root + +Arguments: + [SCOPE] Limit removal to node, pm, or a package-manager family + +Options: + --target Explicitly choose which pin source to remove + Possible values: + - node-version: Pin via the .node-version file + - nvmrc: Pin via the .nvmrc file + - dev-engines: Pin via package.json#devEngines (runtime or packageManager) + - package-manager: Pin via the top-level packageManager field + -h, --help Print help (see a summary with '-h') + Alias for pin --unpin. Node.js pins use the current directory. Package-manager + pins use the workspace root package.json, or the current project package.json + outside a workspace. Removing both components can update two different files. + --target selects a pin source, not a project directory. + +Documentation: https://viteplus.dev/guide/env +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_standalone_package_manager_target.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_standalone_package_manager_target.md new file mode 100644 index 0000000000..47410b2a60 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_standalone_package_manager_target.md @@ -0,0 +1,52 @@ +# env_pin_standalone_package_manager_target + +## `vpt write-file package.json '{"name": "standalone"} +'` + + +## `vp env pin yarn@4.12.0 --no-install` + +first write uses this project manifest without a confirmation + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned package manager to yarn@4.12.0 + File: package.json + Field: devEngines.packageManager +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file package.json` + +``` +{ + "name": "standalone", + "devEngines": { + "packageManager": { + "name": "yarn", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vp env unpin pm` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed package-manager pin + File: package.json + Field: devEngines.packageManager +``` + +## `vpt print-file package.json` + +``` +{ + "name": "standalone", + "devEngines": {} +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_top_level_no_op_and_overwrite.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_top_level_no_op_and_overwrite.md new file mode 100644 index 0000000000..22d6b3cae0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_top_level_no_op_and_overwrite.md @@ -0,0 +1,50 @@ +# env_pin_top_level_no_op_and_overwrite + +## `vpt write-file package.json '{"packageManager": "pnpm@10.18.0+sha512.keep"} +'` + + +## `vp env pin pnpm@10.18.0 --no-install` + +same-value pin preserves the integrity suffix and reports no changes + +``` +VITE+ - The Unified Toolchain for the Web + +Already pinned to pnpm@10.18.0. No changes made. + File: package.json + Field: packageManager +``` + +## `vpt print-file package.json` + +the no-op preserves the original declaration + +``` +{"packageManager": "pnpm@10.18.0+sha512.keep"} +``` + +## `vpt pipe-stdin 'y +' -- vp env pin pnpm@10.19.0 --no-install` + +the target precedes confirmation and follows success + +``` +VITE+ - The Unified Toolchain for the Web + + File: package.json + Field: packageManager +Package manager already pinned to pnpm@10.18.0 +Overwrite with pnpm@10.19.0? (Y/n): ✓ Pinned package manager to pnpm@10.19.0 + File: package.json + Field: packageManager +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file package.json` + +``` +{ + "packageManager": "pnpm@10.19.0" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_from_nested_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_from_nested_workspace.md new file mode 100644 index 0000000000..0ee06c7693 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_from_nested_workspace.md @@ -0,0 +1,38 @@ +# env_unpin_package_manager_from_nested_workspace + +## `vp env unpin pm` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed package-manager pin + File: ../../package.json + Field: devEngines.packageManager +``` + +## `vpt print-file ../../package.json package.json` + +remove the root manager and preserve the member manifest and sibling manager + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "pnpm", + "version": "", + "onFail": "download" + } + ] + } +} +{ + "name": "app", + "private": true +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md index 5539bee4f0..a0ba46971d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md @@ -11,7 +11,9 @@ an explicit top-level target does not remove a devEngines-only package-manager p ``` VITE+ - The Unified Toolchain for the Web -No package manager pin found in current directory. +No matching package-manager pin found. No changes made. + File: package.json + Checked fields: packageManager ``` ## `vpt print-file package.json` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_reports_empty_and_missing_manifest.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_reports_empty_and_missing_manifest.md new file mode 100644 index 0000000000..2ee130b1d5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_reports_empty_and_missing_manifest.md @@ -0,0 +1,51 @@ +# env_unpin_reports_empty_and_missing_manifest + +## `vpt write-file package.json '{"name": "empty"} +'` + + +## `vp env unpin pm` + +``` +VITE+ - The Unified Toolchain for the Web + +No matching package-manager pin found. No changes made. + File: package.json + Checked fields: devEngines.packageManager +``` + +## `vp env unpin yarn --target dev-engines` + +``` +VITE+ - The Unified Toolchain for the Web + +No matching package-manager pin found. No changes made. + File: package.json + Checked fields: devEngines.packageManager + Scope: yarn +``` + +## `vp env unpin pm --target package-manager` + +``` +VITE+ - The Unified Toolchain for the Web + +No matching package-manager pin found. No changes made. + File: package.json + Checked fields: packageManager +``` + +## `vpt rm package.json` + +``` +``` + +## `vp env unpin pm` + +a missing file is distinct from a readable manifest with no matching pin + +``` +VITE+ - The Unified Toolchain for the Web + +No package.json found at package.json. No changes made. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_scoped_manager_falls_back_to_dev_engines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_scoped_manager_falls_back_to_dev_engines.md new file mode 100644 index 0000000000..b0bb6e0bbd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_scoped_manager_falls_back_to_dev_engines.md @@ -0,0 +1,59 @@ +# env_unpin_scoped_manager_falls_back_to_dev_engines + +## `vpt write-file package.json '{"packageManager": "pnpm@10.18.0", "devEngines": {"packageManager": [{"name": "npm", "version": "11.0.0"}, {"name": "yarn", "version": "4.12.0"}]}} +'` + + +## `vp env unpin yarn` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed package-manager pin + File: package.json + Field: devEngines.packageManager +``` + +## `vpt print-file package.json` + +only the matching devEngines entry was removed + +``` +{ + "packageManager": "pnpm@10.18.0", + "devEngines": { + "packageManager": [ + { + "name": "npm", + "version": "" + } + ] + } +} +``` + +## `vp env unpin yarn` + +a miss reports both searched fields and the explicit scope + +``` +VITE+ - The Unified Toolchain for the Web + +No matching package-manager pin found. No changes made. + File: package.json + Checked fields: packageManager, devEngines.packageManager + Scope: yarn +``` + +## `vp env unpin yarn --target package-manager` + +an explicit top-level target checks only that field + +``` +VITE+ - The Unified Toolchain for the Web + +No matching package-manager pin found. No changes made. + File: package.json + Checked fields: packageManager + Scope: yarn +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md index eac0ebfc87..2a25c4eba0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md @@ -12,6 +12,8 @@ an explicit target removes the devEngines manager even when packageManager shado VITE+ - The Unified Toolchain for the Web ✓ Removed package-manager pin + File: package.json + Field: devEngines.packageManager ``` ## `vpt print-file package.json` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md index f9af7ba490..39008666d5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md @@ -38,6 +38,8 @@ a qualified spec pins only the package manager VITE+ - The Unified Toolchain for the Web ✓ Pinned package manager to pnpm@10.18.0 + File: package.json + Field: devEngines.packageManager note: Package manager will be downloaded on first use. ``` @@ -145,6 +147,8 @@ VITE+ - The Unified Toolchain for the Web ✓ Removed devEngines.runtime node entry from /package.json ✓ Removed package-manager pin + File: package.json + Field: devEngines.packageManager ``` ## `vpt print-file package.json` diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index db86da75db..2a322e6e9e 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -30,6 +30,7 @@ tracing = { workspace = true } console = { workspace = true } same-file = { workspace = true } oxc_resolver = { workspace = true } +pathdiff = { workspace = true } vt_pty_terminal_test_client = { workspace = true, features = ["testing"] } rustc-hash = { workspace = true } crossterm = { workspace = true } diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index d6bdb259b3..75349fca90 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -366,7 +366,7 @@ Examples: tool: String, }, - /// Pin Node.js and package-manager versions in the current directory + /// Pin Node.js locally and package-manager versions at the workspace root #[command(after_long_help = "\ Examples: vp env pin lts # Pin Node.js to latest LTS @@ -376,15 +376,21 @@ Examples: vp env pin \"^20.0.0\" --force # Overwrite existing pin vp env pin 24 --target node-version # Force the .node-version file -The write target follows the compatibility-first rule: an existing .node-version -keeps being updated, as does an effective .nvmrc in the current directory. +Node.js pins use the current directory. Package-manager pins use the workspace +root package.json, or the current project package.json outside a workspace. +Pinning both components can update two different files. --target selects a pin +source, not a project directory. + +The Node.js write target follows the compatibility-first rule: an existing +.node-version keeps being updated, as does an effective .nvmrc in the current +directory. Otherwise the pin is written to package.json#devEngines.runtime, or .node-version when the directory has no package.json.")] Pin { /// Versions to pin. Bare versions select Node.js; package managers use name@version. specs: Vec, - /// Remove the pin from the current directory + /// Remove Node.js pins locally and package-manager pins at the workspace root #[arg(long)] unpin: bool, @@ -401,7 +407,12 @@ when the directory has no package.json.")] target: Option, }, - /// Remove environment pins from the current directory (alias for `pin --unpin`) + /// Remove Node.js pins locally and package-manager pins at the workspace root + #[command(after_long_help = "\ +Alias for pin --unpin. Node.js pins use the current directory. Package-manager +pins use the workspace root package.json, or the current project package.json +outside a workspace. Removing both components can update two different files. +--target selects a pin source, not a project directory.")] Unpin { /// Limit removal to node, pm, or a package-manager family scope: Option, @@ -537,7 +548,7 @@ pub enum PinTarget { NodeVersion, /// Pin via the .nvmrc file Nvmrc, - /// Pin via package.json#devEngines.runtime + /// Pin via package.json#devEngines (runtime or packageManager) DevEngines, /// Pin via the top-level packageManager field PackageManager, diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index d70eb1fa22..7fcd9f3803 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -84,6 +84,7 @@ pub async fn execute( if let Some((package_manager, version, hash)) = specs.package_manager { pin_package_manager( &package_manager_root, + &cwd, package_manager, &version, hash.as_deref(), @@ -822,7 +823,24 @@ pub async fn do_unpin_scope( Ok(ExitStatus::default()) } +fn package_manager_display_path( + path: &AbsolutePathBuf, + cwd: &AbsolutePathBuf, +) -> std::path::PathBuf { + pathdiff::diff_paths(path.as_path(), cwd.as_path()) + .unwrap_or_else(|| path.as_path().to_path_buf()) +} + +fn print_package_manager_target(path: &AbsolutePathBuf, cwd: &AbsolutePathBuf, field: &str) { + vp_shared::output::print_stdout_line(format_args!( + " File: {}", + package_manager_display_path(path, cwd).display() + )); + vp_shared::output::print_stdout_line(format_args!(" Field: {field}")); +} + async fn pin_package_manager( + root: &AbsolutePathBuf, cwd: &AbsolutePathBuf, package_manager: PackageManagerType, version: &str, @@ -834,13 +852,14 @@ async fn pin_package_manager( if matches!(target, Some(PinTarget::NodeVersion | PinTarget::Nvmrc)) { return Err(Error::Other("Node.js file targets cannot pin a package manager".into())); } - let resolved = resolve_package_manager_version_for_cwd(cwd, package_manager, version).await?; - package_manager::warn_if_target_differs(cwd, package_manager).await; - let package_json_path = cwd.join(PACKAGE_JSON_FILE); + let resolved = resolve_package_manager_version_for_cwd(root, package_manager, version).await?; + package_manager::warn_if_target_differs(root, package_manager).await; + let package_json_path = root.join(PACKAGE_JSON_FILE); let content = tokio::fs::read_to_string(&package_json_path).await?; let package_json: serde_json::Value = serde_json::from_str(&content)?; let use_top_level = matches!(target, Some(PinTarget::PackageManager)) || (target.is_none() && package_json.get("packageManager").is_some()); + let field = if use_top_level { "packageManager" } else { "devEngines.packageManager" }; let shadowing_top_level = if use_top_level { None } else { existing_package_manager_pin(&content, true) }; let shadowing_top_level = shadowing_top_level.filter(|(name, version)| { @@ -850,10 +869,14 @@ async fn pin_package_manager( if let Some((name, version)) = existing_package_manager_pin(&content, use_top_level) { let existing = format!("{name}@{version}"); let next = format!("{package_manager}@{resolved}"); - if existing != next - && !confirm_overwrite_pin("Package manager already pinned to", &existing, &next, force)? - { - return Ok(ExitStatus::default()); + if existing != next { + if !force { + print_package_manager_target(&package_json_path, cwd, field); + } + if !confirm_overwrite_pin("Package manager already pinned to", &existing, &next, force)? + { + return Ok(ExitStatus::default()); + } } } let mut changed = false; @@ -887,13 +910,15 @@ async fn pin_package_manager( .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; if !changed { vp_shared::output::print_stdout_line(format_args!( - "Already pinned to {package_manager}@{resolved}" + "Already pinned to {package_manager}@{resolved}. No changes made." )); + print_package_manager_target(&package_json_path, cwd, field); return Ok(ExitStatus::default()); } tokio::fs::write(&package_json_path, updated).await?; crate::shim::invalidate_cache(); output::success(&format!("Pinned package manager to {package_manager}@{resolved}")); + print_package_manager_target(&package_json_path, cwd, field); if let Some((name, version)) = shadowing_top_level { output::warn(&format!( "Top-level packageManager {name}@{version} remains effective; remove or update it to use devEngines.packageManager {package_manager}@{resolved}." @@ -902,7 +927,7 @@ async fn pin_package_manager( if no_install { output::note("Package manager will be downloaded on first use."); } else if let Err(error) = - download_package_manager_for_cwd(cwd, package_manager, &resolved, hash).await + download_package_manager_for_cwd(root, package_manager, &resolved, hash).await { output::warn(&format!("Failed to download {package_manager} {resolved}: {error}")); } @@ -984,11 +1009,15 @@ async fn unpin_package_manager( } let root = workspace_root(cwd)?.unwrap_or_else(|| cwd.clone()); let package_json_path = root.join(PACKAGE_JSON_FILE); - let Ok(content) = tokio::fs::read_to_string(&package_json_path).await else { - vp_shared::output::print_stdout_line(format_args!( - "No package manager pin found in current directory." - )); - return Ok(()); + let content = match tokio::fs::read_to_string(&package_json_path).await { + Ok(content) => content, + Err(error) => { + vp_shared::output::print_stdout_line(format_args!( + "{}", + package_manager_read_failure(&package_json_path, cwd, &error) + )); + return Ok(()); + } }; let effective = resolve_package_manager_from_package_json(&root)?; let expected = match scope { @@ -1007,9 +1036,16 @@ async fn unpin_package_manager( _ => effective.as_ref().map(|resolution| resolution.package_manager_type), }; let mut changed = false; + let mut checked_fields = Vec::new(); + let mut removed_field = None; let updated = vp_shared::edit_json_object(&content, |obj| { let remove_top_level = matches!(target, Some(PinTarget::PackageManager)) || (target.is_none() && obj.get("packageManager").is_some()); + checked_fields.push(if remove_top_level { + "packageManager" + } else { + "devEngines.packageManager" + }); let top_level_matches = expected.is_none_or(|expected| { obj.get("packageManager") .and_then(serde_json::Value::as_str) @@ -1019,10 +1055,15 @@ async fn unpin_package_manager( }); if remove_top_level && top_level_matches { changed = obj.remove("packageManager").is_some(); + removed_field = Some("packageManager"); } else if !matches!(target, Some(PinTarget::PackageManager)) && let Some(expected) = expected { + if remove_top_level { + checked_fields.push("devEngines.packageManager"); + } changed = remove_dev_engines_package_manager(obj, expected); + removed_field = Some("devEngines.packageManager"); } }) .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; @@ -1030,14 +1071,42 @@ async fn unpin_package_manager( tokio::fs::write(&package_json_path, updated).await?; crate::shim::invalidate_cache(); output::success("Removed package-manager pin"); + if let Some(field) = removed_field { + print_package_manager_target(&package_json_path, cwd, field); + } } else { vp_shared::output::print_stdout_line(format_args!( - "No package manager pin found in current directory." + "No matching package-manager pin found. No changes made." )); + vp_shared::output::print_stdout_line(format_args!( + " File: {}", + package_manager_display_path(&package_json_path, cwd).display() + )); + vp_shared::output::print_stdout_line(format_args!( + " Checked fields: {}", + checked_fields.join(", ") + )); + if let EnvScope::PackageManager(package_manager) = scope { + vp_shared::output::print_stdout_line(format_args!(" Scope: {package_manager}")); + } } Ok(()) } +fn package_manager_read_failure( + path: &AbsolutePathBuf, + cwd: &AbsolutePathBuf, + error: &std::io::Error, +) -> String { + let display_path = package_manager_display_path(path, cwd); + let path = display_path.display(); + if error.kind() == std::io::ErrorKind::NotFound { + format!("No package.json found at {path}. No changes made.") + } else { + format!("Could not read {path}: {error}\nNo changes made.") + } +} + fn workspace_root(cwd: &AbsolutePathBuf) -> Result, Error> { match vt_workspace::find_workspace_root(cwd) { Ok((workspace, _)) => Ok(Some(workspace.path.to_absolute_path_buf())), @@ -1091,6 +1160,27 @@ mod tests { use super::*; + #[test] + fn package_manager_read_failure_does_not_claim_fields_were_checked() { + let temp = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let path = cwd.join("package.json"); + assert_eq!( + package_manager_read_failure(&path, &cwd, &std::io::ErrorKind::NotFound.into()), + "No package.json found at package.json. No changes made." + ); + let denied = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied"); + assert_eq!( + package_manager_read_failure(&path, &cwd, &denied), + "Could not read package.json: access denied\nNo changes made." + ); + let invalid = std::io::Error::new(std::io::ErrorKind::InvalidData, "invalid UTF-8"); + assert_eq!( + package_manager_read_failure(&path, &cwd, &invalid), + "Could not read package.json: invalid UTF-8\nNo changes made." + ); + } + /// Shared VP_HOME for tests that hit the real Node.js version index: /// pinning isolates them from concurrent scopes, and one shared root /// keeps the index cache warm across tests and runs. @@ -1145,9 +1235,18 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", None, true, true, None) - .await - .unwrap(); + pin_package_manager( + &cwd, + &cwd, + PackageManagerType::Pnpm, + "10.18.0", + None, + true, + true, + None, + ) + .await + .unwrap(); let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); assert!(content.contains("pnpm@10.18.0+sha512.keep")); @@ -1164,9 +1263,18 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", None, true, true, None) - .await - .unwrap(); + pin_package_manager( + &cwd, + &cwd, + PackageManagerType::Pnpm, + "10.18.0", + None, + true, + true, + None, + ) + .await + .unwrap(); let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); assert!(content.contains("pnpm@10.18.0")); @@ -1185,6 +1293,7 @@ mod tests { .unwrap(); pin_package_manager( + &cwd, &cwd, PackageManagerType::Pnpm, "10.18.0", diff --git a/docs/guide/env.md b/docs/guide/env.md index 0f91011abf..f7ef2eb8e6 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -189,8 +189,13 @@ The global CLI supports Tab completion in Bash, Zsh, Fish, Nushell, and PowerShe - `vp env default` shows the global Node.js default and each configured package-manager version. Bare versions set Node.js; qualified specs such as `pnpm@10.18.0` set that package manager's shim default without replacing the defaults for Bun, Yarn, or npm. `--unset` clears all defaults unless scoped. - `vp env default ` and package-manager dist-tags such as `pnpm@latest` or `pnpm@latest-10` re-resolve on each use, so the default follows the tag, including new majors for `latest`. Other versions and ranges, such as `24` or `pnpm@10`, resolve to an exact version when set. -- `vp env pin` shows or writes project pins. Existing `.node-version` and top-level `packageManager` fields keep being updated for compatibility. An existing `.nvmrc` is updated when it is the effective Node source in the current directory; its comments and other non-version content are preserved. Otherwise Vite+ writes the matching `devEngines` entry. Use `--target node-version`, `--target nvmrc`, `--target dev-engines`, or `--target package-manager` to choose explicitly. Pinning in a child directory does not modify an inherited `.nvmrc`. -- `vp env unpin` removes both effective pins by default; append a selector to remove one. Lower-priority declarations are not deleted. +- `vp env pin` shows or writes project pins. Node.js pins use the current directory. Package-manager pins use the workspace root `package.json`, or the current project `package.json` outside a workspace. Pinning both components can update two different files. + + Existing `.node-version` and top-level `packageManager` fields keep being updated for compatibility. An existing `.nvmrc` is updated when it is the effective Node source in the current directory. Its comments and other non-version content are preserved. Otherwise Vite+ writes the matching `devEngines` entry. + + Use `--target node-version`, `--target nvmrc`, `--target dev-engines`, or `--target package-manager` to choose the pin source explicitly. This does not change the project directory. Package-manager output shows the file path relative to the current working directory and the field that was updated, or the target before an existing overwrite prompt. Pinning in a child directory does not modify an inherited `.nvmrc`. + +- `vp env unpin` (alias for `vp env pin --unpin`) removes both effective pins by default. Append a selector to remove one. Node.js removal uses the current directory. Package-manager removal uses the workspace root manifest, or the current project manifest outside a workspace. `--target` selects the pin source within that scope. The output identifies the removed package-manager field, or the fields checked when no matching pin is found. Lower-priority declarations are not deleted. - `vp env use` activates the complete project environment. Explicit specs override selected components; `--unset` clears both unless scoped. - `vp env install` installs the complete resolved environment, a selected component, or explicit specs. - `vp env uninstall` removes the Node.js version resolved from an explicit version or selector, such as `22` or `lts`. Package managers require a qualified exact version, such as `pnpm@10.18.0`.