diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots.toml index e5ec623e3f..1497a40d28 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots.toml @@ -15,6 +15,7 @@ steps = [ { argv = ["vp", "why", "-h"], comment = "show why help message" }, { argv = ["vp", "info", "-h"], comment = "show info help message" }, { argv = ["vp", "pm", "-h"], comment = "show pm help message" }, + { argv = ["vp", "pm", "ci", "-h"], comment = "show pm ci help message" }, { argv = ["vp", "env"], comment = "show env help message" }, { argv = ["vp", "upgrade", "-h"], comment = "show upgrade help message" }, ] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 7a51254a8e..ea58921e1a 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -396,6 +396,7 @@ Usage: vp pm Forward a command to the package manager Commands: + ci Clean install dependencies for CI environments approve-builds Approve dependency lifecycle scripts (install/postinstall) to run prune Remove unnecessary packages pack Create a tarball of the package @@ -424,6 +425,26 @@ Options: Documentation: https://viteplus.dev/guide/install ``` +## `vp pm ci -h` + +show pm ci help message + +``` +VITE+ - The Unified Toolchain for the Web + +Usage: vp pm ci [-- ...] + +Clean install dependencies for CI environments + +Arguments: + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + -h, --help Print help + +Documentation: https://viteplus.dev/guide/install +``` + ## `vp env` show env help message diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/package.json new file mode 100644 index 0000000000..00f56075c1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/package.json @@ -0,0 +1,4 @@ +{ + "name": "pm-ci-snapshot", + "private": true +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/scripts/setup-fake-pms.cjs b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/scripts/setup-fake-pms.cjs new file mode 100644 index 0000000000..adaaac242b --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/scripts/setup-fake-pms.cjs @@ -0,0 +1,38 @@ +const { chmodSync, mkdirSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const vpHome = process.env.VP_HOME; +if (!vpHome) { + throw new Error('VP_HOME is required'); +} + +const packageManagers = [ + ['pnpm', '11.0.0'], + ['npm', '10.5.0'], + ['yarn', '1.22.22'], + ['yarn', '4.0.0'], + ['bun', '1.2.0'], +]; + +for (const [name, version] of packageManagers) { + const binDir = join(vpHome, 'package_manager', name, version, name, 'bin'); + mkdirSync(binDir, { recursive: true }); + + const fakePm = join(binDir, 'fake-pm.cjs'); + writeFileSync( + fakePm, + `const args = process.argv.slice(2); +console.log(${JSON.stringify(name)} + (args.length ? ' ' + args.join(' ') : '')); +`, + ); + + const unixShim = join(binDir, name); + writeFileSync(unixShim, "#!/usr/bin/env node\nrequire('./fake-pm.cjs');\n"); + chmodSync(unixShim, 0o755); + + writeFileSync(join(binDir, `${name}.cmd`), `@echo off\r\nnode "%~dp0fake-pm.cjs" %*\r\n`); + writeFileSync( + join(binDir, `${name}.ps1`), + 'node "$PSScriptRoot/fake-pm.cjs" @args\nexit $LASTEXITCODE\n', + ); +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/snapshots.toml new file mode 100644 index 0000000000..7c0ae8a5c3 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/snapshots.toml @@ -0,0 +1,26 @@ +[[case]] +name = "pm_ci_package_managers" +vp = "global" +comment = """ +Covers `vp pm ci` command delegation for each supported package manager. +Fake managed package-manager installs live under VP_HOME, so the case records +the exact argv Vite+ delegates without hitting the network or real installers. +""" +steps = [ + { argv = ["node", "scripts/setup-fake-pms.cjs"], snapshot = false }, + + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@11.0.0"], snapshot = false }, + { argv = ["vp", "pm", "ci"], comment = "pnpm uses frozen-lockfile install" }, + + { argv = ["vpt", "json-edit", "package.json", "packageManager", "npm@10.5.0"], snapshot = false }, + { argv = ["vp", "pm", "ci"], comment = "npm keeps native ci delegation because npm has no frozen-lockfile install flag" }, + + { argv = ["vpt", "json-edit", "package.json", "packageManager", "yarn@1.22.22"], snapshot = false }, + { argv = ["vp", "pm", "ci"], comment = "Yarn Classic uses frozen-lockfile install" }, + + { argv = ["vpt", "json-edit", "package.json", "packageManager", "yarn@4.0.0"], snapshot = false }, + { argv = ["vp", "pm", "ci"], comment = "Yarn Berry uses immutable install" }, + + { argv = ["vpt", "json-edit", "package.json", "packageManager", "bun@1.2.0"], snapshot = false }, + { argv = ["vp", "pm", "ci"], comment = "Bun uses frozen-lockfile install" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/snapshots/pm_ci_package_managers.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/snapshots/pm_ci_package_managers.md new file mode 100644 index 0000000000..5b2726e769 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pm_ci/snapshots/pm_ci_package_managers.md @@ -0,0 +1,63 @@ +# pm_ci_package_managers + +Covers `vp pm ci` command delegation for each supported package manager. +Fake managed package-manager installs live under VP_HOME, so the case records +the exact argv Vite+ delegates without hitting the network or real installers. + +## `node scripts/setup-fake-pms.cjs` + + +## `vpt json-edit package.json packageManager pnpm@11.0.0` + + +## `vp pm ci` + +pnpm uses frozen-lockfile install + +``` +pnpm install --frozen-lockfile +``` + +## `vpt json-edit package.json packageManager npm@10.5.0` + + +## `vp pm ci` + +npm keeps native ci delegation because npm has no frozen-lockfile install flag + +``` +npm ci +``` + +## `vpt json-edit package.json packageManager yarn@1.22.22` + + +## `vp pm ci` + +Yarn Classic uses frozen-lockfile install + +``` +yarn install --frozen-lockfile +``` + +## `vpt json-edit package.json packageManager yarn@4.0.0` + + +## `vp pm ci` + +Yarn Berry uses immutable install + +``` +yarn install --immutable +``` + +## `vpt json-edit package.json packageManager bun@1.2.0` + + +## `vp pm ci` + +Bun uses frozen-lockfile install + +``` +bun install --frozen-lockfile +``` diff --git a/crates/vite_install/src/commands/ci.rs b/crates/vite_install/src/commands/ci.rs new file mode 100644 index 0000000000..11bb8f6fcc --- /dev/null +++ b/crates/vite_install/src/commands/ci.rs @@ -0,0 +1,151 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_command::run_command; +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, +}; + +/// Options for the ci command. +#[derive(Debug, Default)] +pub struct CiCommandOptions<'a> { + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run the ci command with the package manager. + #[must_use] + pub async fn run_ci_command( + &self, + options: &CiCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_ci_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the ci command. + #[must_use] + pub fn resolve_ci_command(&self, options: &CiCommandOptions<'_>) -> ResolveCommandResult { + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let command = |bin_path: &str, mut args: Vec| { + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: bin_path.into(), args, envs: envs.clone() } + }; + + match self.client { + PackageManagerType::Npm => command("npm", vec!["ci".into()]), + PackageManagerType::Pnpm => { + command("pnpm", vec!["install".into(), "--frozen-lockfile".into()]) + } + PackageManagerType::Bun => { + command("bun", vec!["install".into(), "--frozen-lockfile".into()]) + } + PackageManagerType::Yarn => { + let mut args = vec!["install".into()]; + if self.is_yarn_berry() { + args.push("--immutable".into()); + } else { + args.push("--frozen-lockfile".into()); + } + command("yarn", args) + } + } + } +} + +#[cfg(test)] +mod tests { + use tempfile::{TempDir, tempdir}; + use vite_path::AbsolutePathBuf; + use vite_str::Str; + + use super::*; + + fn create_temp_dir() -> TempDir { + tempdir().expect("Failed to create temp directory") + } + + fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let install_dir = temp_dir_path.join("install"); + + PackageManager { + client: pm_type, + package_name: pm_type.to_string().into(), + version: Str::from(version), + hash: None, + bin_name: pm_type.to_string().into(), + workspace_root: temp_dir_path.clone(), + is_monorepo: false, + install_dir, + } + } + + fn assert_path_env(command: &ResolveCommandResult) { + assert!(command.envs.contains_key("PATH")); + } + + #[test] + fn test_npm_ci() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_ci_command(&CiCommandOptions::default()); + assert_path_env(&result); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["ci"]); + } + + #[test] + fn test_pnpm_ci() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.0"); + let result = pm.resolve_ci_command(&CiCommandOptions::default()); + assert_path_env(&result); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["install", "--frozen-lockfile"]); + } + + #[test] + fn test_bun_ci() { + let pm = create_mock_package_manager(PackageManagerType::Bun, "1.3.11"); + let result = pm.resolve_ci_command(&CiCommandOptions::default()); + assert_path_env(&result); + assert_eq!(result.bin_path, "bun"); + assert_eq!(result.args, vec!["install", "--frozen-lockfile"]); + } + + #[test] + fn test_yarn_ci_uses_immutable_install() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_ci_command(&CiCommandOptions::default()); + assert_path_env(&result); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["install", "--immutable"]); + } + + #[test] + fn test_yarn_classic_ci_uses_frozen_lockfile_install() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.22"); + let result = pm.resolve_ci_command(&CiCommandOptions::default()); + assert_path_env(&result); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["install", "--frozen-lockfile"]); + } + + #[test] + fn test_ci_pass_through_args() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.0.0"); + let pass_through = vec!["--ignore-scripts".to_string()]; + let result = + pm.resolve_ci_command(&CiCommandOptions { pass_through_args: Some(&pass_through) }); + assert_path_env(&result); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["install", "--frozen-lockfile", "--ignore-scripts"]); + } +} diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs index e59115a3f0..bd70617d1c 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod add; pub mod approve_builds; pub mod audit; pub mod cache; +pub mod ci; pub mod config; pub mod dedupe; pub mod deprecate; diff --git a/crates/vite_pm_cli/src/cli.rs b/crates/vite_pm_cli/src/cli.rs index 2e2c94ee86..d690b06120 100644 --- a/crates/vite_pm_cli/src/cli.rs +++ b/crates/vite_pm_cli/src/cli.rs @@ -562,6 +562,13 @@ impl PackageManagerCommand { /// Package manager subcommands (`vp pm `). #[derive(Subcommand, Debug, Clone)] pub enum PmCommands { + /// Clean install dependencies for CI environments + Ci { + /// Additional arguments to pass through to the package manager + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + /// Approve dependency lifecycle scripts (install/postinstall) to run #[command(name = "approve-builds")] ApproveBuilds { @@ -1372,6 +1379,24 @@ mod tests { assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); } + #[test] + fn ci_parses() { + let command = parse_pm_command(&["vp", "pm", "ci"]).expect("ci should parse"); + + assert!(matches!(command, PackageManagerCommand::Pm(PmCommands::Ci { .. }))); + } + + #[test] + fn ci_pass_through_args_capture() { + let command = parse_pm_command(&["vp", "pm", "ci", "--", "--ignore-scripts"]) + .expect("pass-through args should parse"); + + let PackageManagerCommand::Pm(PmCommands::Ci { pass_through_args }) = command else { + panic!("expected Ci variant"); + }; + assert_eq!(pass_through_args, Some(vec!["--ignore-scripts".to_string()])); + } + #[test] fn approve_builds_all_conflicts_with_deny_packages() { let error = parse_pm_command(&["vp", "pm", "approve-builds", "--all", "!core-js"]) diff --git a/crates/vite_pm_cli/src/handlers.rs b/crates/vite_pm_cli/src/handlers.rs index 1670c5ec4d..f481d4341a 100644 --- a/crates/vite_pm_cli/src/handlers.rs +++ b/crates/vite_pm_cli/src/handlers.rs @@ -11,6 +11,7 @@ use vite_install::{ approve_builds::ApproveBuildsCommandOptions, audit::AuditCommandOptions, cache::CacheCommandOptions, + ci::CiCommandOptions, config::ConfigCommandOptions, dedupe::DedupeCommandOptions, deprecate::DeprecateCommandOptions, @@ -172,6 +173,7 @@ pub async fn run_pm_subcommand( let needs_project = matches!( command, PmCommands::ApproveBuilds { .. } + | PmCommands::Ci { .. } | PmCommands::Prune { .. } | PmCommands::Pack { .. } | PmCommands::List { .. } @@ -189,6 +191,11 @@ pub async fn run_pm_subcommand( }; match command { + PmCommands::Ci { pass_through_args } => { + let options = CiCommandOptions { pass_through_args: pass_through_args.as_deref() }; + Ok(pm.run_ci_command(&options, cwd).await?) + } + PmCommands::ApproveBuilds { packages, all, pass_through_args } => { let options = ApproveBuildsCommandOptions { packages: &packages,