From c3f240dbe54799fb2c460da71684a312b23c5242 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 30 May 2026 15:27:10 +0800 Subject: [PATCH 1/7] feat(pm): add vp pm stage command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `vp pm stage` exposing npm's staged-publishing workflow (publish/list/view/download/approve/reject) across package managers: pnpm and npm pass through directly; yarn Berry uses its npm plugin (`yarn npm publish --staged`, `yarn npm stage …`); yarn Classic and bun fall back to `npm stage`. Note that `vp pm stage` is unrelated to yarn's own `yarn stage` (VCS staging). Closes #1674 --- crates/vite_install/src/commands/mod.rs | 1 + crates/vite_install/src/commands/stage.rs | 575 ++++++++++++++++++ crates/vite_pm_cli/src/cli.rs | 190 ++++++ crates/vite_pm_cli/src/handlers.rs | 59 +- docs/guide/install.md | 20 + .../command-pm-stage-pnpm10/package.json | 5 + .../command-pm-stage-pnpm10/snap.txt | 59 ++ .../command-pm-stage-pnpm10/steps.json | 7 + rfcs/stage-command.md | 517 ++++++++++++++++ 9 files changed, 1432 insertions(+), 1 deletion(-) create mode 100644 crates/vite_install/src/commands/stage.rs create mode 100644 packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json create mode 100644 packages/cli/snap-tests-global/command-pm-stage-pnpm10/snap.txt create mode 100644 packages/cli/snap-tests-global/command-pm-stage-pnpm10/steps.json create mode 100644 rfcs/stage-command.md diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs index 9b3bbcda86..e59115a3f0 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -22,6 +22,7 @@ pub mod publish; pub mod rebuild; pub mod remove; pub mod search; +pub mod stage; pub mod token; pub mod unlink; pub mod update; diff --git a/crates/vite_install/src/commands/stage.rs b/crates/vite_install/src/commands/stage.rs new file mode 100644 index 0000000000..15f4e8eb29 --- /dev/null +++ b/crates/vite_install/src/commands/stage.rs @@ -0,0 +1,575 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_command::run_command; +use vite_error::Error; +use vite_path::AbsolutePath; +use vite_shared::output; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, +}; + +/// Subcommands for the staged-publishing workflow. +/// +/// Mirrors npm's `npm stage ` and +/// pnpm's `pnpm stage <…>`. yarn berry reaches the same registry feature +/// through its npm plugin (`yarn npm publish --staged`, `yarn npm stage …`). +#[derive(Debug, Clone)] +pub enum StageSubcommand { + /// Upload a package to the staging area (no 2FA required). + Publish { + target: Option, + tag: Option, + access: Option, + otp: Option, + dry_run: bool, + json: bool, + recursive: bool, + filters: Option>, + provenance: bool, + }, + /// List staged versions. + List { package: Option, json: bool }, + /// Show details about a staged version. + View { stage_id: String, json: bool }, + /// Download the staged tarball for inspection. + Download { stage_id: String }, + /// Promote a staged version to the live registry (2FA required). + Approve { stage_id: String, otp: Option }, + /// Discard a staged version (2FA required). + Reject { stage_id: String, otp: Option }, +} + +/// Options for the stage command. +#[derive(Debug)] +pub struct StageCommandOptions<'a> { + pub subcommand: StageSubcommand, + pub registry: Option<&'a str>, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + /// Run the stage command with the package manager. + #[must_use] + pub async fn run_stage_command( + &self, + options: &StageCommandOptions<'_>, + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_stage_command(options); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the stage command. + /// + /// pnpm and npm pass through directly. yarn berry uses its npm plugin + /// (`yarn npm publish --staged` to stage, `yarn npm stage …` to manage), + /// falling back to npm for `view`/`download` which yarn does not expose. + /// yarn 1 and bun have no staged-publishing support and fall back to npm. + /// + /// Note: `yarn stage` is git/VCS staging, not publishing — it is never used + /// here. + #[must_use] + pub fn resolve_stage_command(&self, options: &StageCommandOptions) -> ResolveCommandResult { + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut args: Vec = Vec::new(); + + let bin_name: String; + + match self.client { + PackageManagerType::Pnpm => { + bin_name = "pnpm".into(); + + // pnpm: --filter must come before the command. + if let StageSubcommand::Publish { filters: Some(filters), .. } = &options.subcommand + { + for filter in filters { + args.push("--filter".into()); + args.push(filter.clone()); + } + } + + args.push("stage".into()); + append_stage_subcommand(&mut args, &options.subcommand, true); + } + PackageManagerType::Npm => { + bin_name = "npm".into(); + append_npm_stage(&mut args, &options.subcommand); + } + PackageManagerType::Yarn => { + if self.version.starts_with("1.") { + output::warn( + "yarn 1 does not support staged publishing, falling back to npm stage", + ); + bin_name = "npm".into(); + append_npm_stage(&mut args, &options.subcommand); + } else { + match &options.subcommand { + StageSubcommand::Publish { .. } => { + // yarn berry stages via `yarn npm publish --staged`. + bin_name = "yarn".into(); + append_yarn_publish_staged(&mut args, &options.subcommand); + } + StageSubcommand::List { .. } + | StageSubcommand::Approve { .. } + | StageSubcommand::Reject { .. } => { + bin_name = "yarn".into(); + args.push("npm".into()); + args.push("stage".into()); + append_stage_subcommand(&mut args, &options.subcommand, false); + } + StageSubcommand::View { .. } | StageSubcommand::Download { .. } => { + output::warn( + "yarn does not support 'stage view'/'stage download', falling back to npm stage", + ); + bin_name = "npm".into(); + append_npm_stage(&mut args, &options.subcommand); + } + } + } + } + PackageManagerType::Bun => { + output::warn("bun does not support staged publishing, falling back to npm stage"); + bin_name = "npm".into(); + append_npm_stage(&mut args, &options.subcommand); + } + } + + // `--registry` applies to every variant and is forwarded as-is. + if let Some(registry) = options.registry { + args.push("--registry".into()); + args.push(registry.to_string()); + } + + // Add pass-through args. + if let Some(pass_through_args) = options.pass_through_args { + args.extend_from_slice(pass_through_args); + } + + ResolveCommandResult { bin_path: bin_name, args, envs } + } +} + +/// Build the `npm stage …` argument list (also used as the fallback path for +/// yarn 1, bun, and yarn berry's unsupported `view`/`download`). +fn append_npm_stage(args: &mut Vec, subcommand: &StageSubcommand) { + warn_workspace_unsupported(subcommand, "npm staged publishing"); + args.push("stage".into()); + append_stage_subcommand(args, subcommand, false); +} + +/// Append the ` [args]` portion in npm/pnpm style. +/// +/// `support_workspace` controls whether pnpm's `--recursive` flag is emitted; +/// `--filter` is handled by the caller as a pnpm prefix. +fn append_stage_subcommand( + args: &mut Vec, + subcommand: &StageSubcommand, + support_workspace: bool, +) { + match subcommand { + StageSubcommand::Publish { + target, + tag, + access, + otp, + dry_run, + json, + recursive, + provenance, + filters: _, + } => { + args.push("publish".into()); + if let Some(target) = target { + args.push(target.clone()); + } + if let Some(tag) = tag { + args.push("--tag".into()); + args.push(tag.clone()); + } + if let Some(access) = access { + args.push("--access".into()); + args.push(access.clone()); + } + if let Some(otp) = otp { + args.push("--otp".into()); + args.push(otp.clone()); + } + if *dry_run { + args.push("--dry-run".into()); + } + if *json { + args.push("--json".into()); + } + if support_workspace && *recursive { + args.push("--recursive".into()); + } + if *provenance { + args.push("--provenance".into()); + } + } + StageSubcommand::List { package, json } => { + args.push("list".into()); + if let Some(package) = package { + args.push(package.clone()); + } + if *json { + args.push("--json".into()); + } + } + StageSubcommand::View { stage_id, json } => { + args.push("view".into()); + args.push(stage_id.clone()); + if *json { + args.push("--json".into()); + } + } + StageSubcommand::Download { stage_id } => { + args.push("download".into()); + args.push(stage_id.clone()); + } + StageSubcommand::Approve { stage_id, otp } => { + args.push("approve".into()); + args.push(stage_id.clone()); + if let Some(otp) = otp { + args.push("--otp".into()); + args.push(otp.clone()); + } + } + StageSubcommand::Reject { stage_id, otp } => { + args.push("reject".into()); + args.push(stage_id.clone()); + if let Some(otp) = otp { + args.push("--otp".into()); + args.push(otp.clone()); + } + } + } +} + +/// Build `yarn npm publish --staged …`. yarn berry's npm plugin stages via the +/// publish command; only the flags yarn supports are forwarded. +fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcommand) { + let StageSubcommand::Publish { + target, + tag, + access, + otp, + dry_run, + json, + recursive, + filters, + provenance, + } = subcommand + else { + return; + }; + + args.push("npm".into()); + args.push("publish".into()); + args.push("--staged".into()); + + if target.is_some() { + output::warn("yarn npm publish does not accept a target path, ignoring it"); + } + if let Some(tag) = tag { + args.push("--tag".into()); + args.push(tag.clone()); + } + if let Some(access) = access { + args.push("--access".into()); + args.push(access.clone()); + } + if let Some(otp) = otp { + args.push("--otp".into()); + args.push(otp.clone()); + } + if *provenance { + args.push("--provenance".into()); + } + if *dry_run { + output::warn("--dry-run is not supported by yarn npm publish, ignoring flag"); + } + if *json { + output::warn("--json is not supported by yarn npm publish, ignoring flag"); + } + if *recursive { + output::warn("--recursive is not supported by yarn npm publish, ignoring flag"); + } + if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { + output::warn("--filter is not supported by yarn npm publish, ignoring flag"); + } +} + +/// Warn about workspace flags that the given client cannot honor for staged +/// publishing (only `publish` carries them). +fn warn_workspace_unsupported(subcommand: &StageSubcommand, client: &str) { + if let StageSubcommand::Publish { recursive, filters, .. } = subcommand { + if *recursive { + output::warn(&format!("--recursive is not supported by {client}, ignoring flag")); + } + if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { + output::warn(&format!("--filter is not supported by {client}, ignoring flag")); + } + } +} + +#[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 publish_sub_full( + tag: Option<&str>, + access: Option<&str>, + recursive: bool, + filters: Option>, + provenance: bool, + ) -> StageSubcommand { + StageSubcommand::Publish { + target: None, + tag: tag.map(Into::into), + access: access.map(Into::into), + otp: None, + dry_run: false, + json: false, + recursive, + filters, + provenance, + } + } + + fn publish_sub() -> StageSubcommand { + publish_sub_full(None, None, false, None, false) + } + + fn opts(subcommand: StageSubcommand) -> StageCommandOptions<'static> { + StageCommandOptions { subcommand, registry: None, pass_through_args: None } + } + + #[test] + fn test_pnpm_stage_publish() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); + let result = pm.resolve_stage_command(&opts(publish_sub())); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["stage", "publish"]); + } + + #[test] + fn test_pnpm_stage_publish_with_tag_access() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); + let result = pm.resolve_stage_command(&opts(publish_sub_full( + Some("next"), + Some("public"), + false, + None, + false, + ))); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["stage", "publish", "--tag", "next", "--access", "public"]); + } + + #[test] + fn test_pnpm_stage_publish_recursive_filter() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); + let result = pm.resolve_stage_command(&opts(publish_sub_full( + None, + None, + true, + Some(vec!["app".into()]), + false, + ))); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["--filter", "app", "stage", "publish", "--recursive"]); + } + + #[test] + fn test_npm_stage_publish() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); + let result = pm.resolve_stage_command(&opts(publish_sub())); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "publish"]); + } + + #[test] + fn test_npm_stage_publish_recursive_ignored() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); + let result = pm.resolve_stage_command(&opts(publish_sub_full( + None, + None, + true, + Some(vec!["app".into()]), + false, + ))); + // npm staged publishing has no workspace flags; they are dropped. + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "publish"]); + } + + #[test] + fn test_npm_stage_list_with_package_json() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::List { + package: Some("my-pkg".into()), + json: true, + })); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "list", "my-pkg", "--json"]); + } + + #[test] + fn test_npm_stage_view() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::View { + stage_id: "abc123".into(), + json: false, + })); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "view", "abc123"]); + } + + #[test] + fn test_npm_stage_download() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); + let result = pm + .resolve_stage_command(&opts(StageSubcommand::Download { stage_id: "abc123".into() })); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "download", "abc123"]); + } + + #[test] + fn test_stage_approve_with_otp() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::Approve { + stage_id: "abc123".into(), + otp: Some("123456".into()), + })); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["stage", "approve", "abc123", "--otp", "123456"]); + } + + #[test] + fn test_stage_reject() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.15.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::Reject { + stage_id: "abc123".into(), + otp: None, + })); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "reject", "abc123"]); + } + + #[test] + fn test_yarn_berry_stage_publish_uses_npm_plugin() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_stage_command(&opts(publish_sub_full( + Some("next"), + None, + false, + None, + false, + ))); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "publish", "--staged", "--tag", "next"]); + } + + #[test] + fn test_yarn_berry_stage_list() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = + pm.resolve_stage_command(&opts(StageSubcommand::List { package: None, json: false })); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "stage", "list"]); + } + + #[test] + fn test_yarn_berry_stage_approve() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::Approve { + stage_id: "abc123".into(), + otp: None, + })); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "stage", "approve", "abc123"]); + } + + #[test] + fn test_yarn_berry_stage_view_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::View { + stage_id: "abc123".into(), + json: false, + })); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "view", "abc123"]); + } + + #[test] + fn test_yarn1_stage_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0"); + let result = pm.resolve_stage_command(&opts(publish_sub())); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "publish"]); + } + + #[test] + fn test_bun_stage_falls_back_to_npm() { + let pm = create_mock_package_manager(PackageManagerType::Bun, "1.2.0"); + let result = pm.resolve_stage_command(&opts(publish_sub())); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "publish"]); + } + + #[test] + fn test_stage_registry_appended() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); + let result = pm.resolve_stage_command(&StageCommandOptions { + subcommand: StageSubcommand::List { package: None, json: false }, + registry: Some("https://registry.example.com"), + pass_through_args: None, + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!( + result.args, + vec!["stage", "list", "--registry", "https://registry.example.com"] + ); + } + + #[test] + fn test_stage_pass_through_args() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "11.3.0"); + let extra = vec!["--foo".to_string()]; + let result = pm.resolve_stage_command(&StageCommandOptions { + subcommand: publish_sub(), + registry: None, + pass_through_args: Some(&extra), + }); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["stage", "publish", "--foo"]); + } +} diff --git a/crates/vite_pm_cli/src/cli.rs b/crates/vite_pm_cli/src/cli.rs index c82537e9ed..a17cbe5bac 100644 --- a/crates/vite_pm_cli/src/cli.rs +++ b/crates/vite_pm_cli/src/cli.rs @@ -766,6 +766,10 @@ pub enum PmCommands { pass_through_args: Option>, }, + /// Stage a package for publishing (npm staged publishing workflow) + #[command(subcommand)] + Stage(StageCommands), + /// Manage package owners #[command(subcommand, visible_alias = "author")] Owner(OwnerCommands), @@ -948,6 +952,7 @@ impl PmCommands { | Self::Fund { json, .. } => *json, Self::Config(sub) => sub.is_quiet_or_machine_readable(), Self::Token(sub) => sub.is_quiet_or_machine_readable(), + Self::Stage(sub) => sub.is_quiet_or_machine_readable(), _ => false, } } @@ -1168,6 +1173,157 @@ pub enum DistTagCommands { }, } +/// Staged-publishing subcommands (`vp pm stage `). +/// +/// Maps to `npm stage`/`pnpm stage` and yarn berry's npm plugin +/// (`yarn npm publish --staged`, `yarn npm stage …`). Note: this is unrelated +/// to yarn's own `yarn stage` command, which stages files for a VCS commit. +#[derive(Subcommand, Debug, Clone)] +pub enum StageCommands { + /// Stage a package for publishing (no 2FA required) + Publish { + /// Tarball or folder to stage + #[arg(value_name = "TARBALL|FOLDER")] + target: Option, + + /// Publish tag + #[arg(long)] + tag: Option, + + /// Access level (public/restricted) + #[arg(long)] + access: Option, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Preview without staging + #[arg(long)] + dry_run: bool, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Stage all publishable workspace packages + #[arg(short = 'r', long)] + recursive: bool, + + /// Filter packages in monorepo + #[arg(long, value_name = "PATTERN")] + filter: Option>, + + /// Stage with provenance + #[arg(long)] + provenance: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// List staged versions + #[command(visible_alias = "ls")] + List { + /// Package spec to filter by + package: Option, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Show details about a staged version + View { + /// Stage ID + stage_id: String, + + /// Output in JSON format + #[arg(long)] + json: bool, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Download the staged tarball for inspection + Download { + /// Stage ID + stage_id: String, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Promote a staged version to the live registry (2FA required) + Approve { + /// Stage ID + stage_id: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, + + /// Discard a staged version (2FA required) + Reject { + /// Stage ID + stage_id: String, + + /// One-time password for authentication + #[arg(long, value_name = "OTP")] + otp: Option, + + /// Registry URL + #[arg(long, value_name = "URL")] + registry: Option, + + /// Additional arguments + #[arg(last = true, allow_hyphen_values = true)] + pass_through_args: Option>, + }, +} + +impl StageCommands { + pub fn is_quiet_or_machine_readable(&self) -> bool { + match self { + Self::Publish { json, .. } | Self::List { json, .. } | Self::View { json, .. } => *json, + _ => false, + } + } +} + #[cfg(test)] mod tests { use clap::FromArgMatches; @@ -1259,4 +1415,38 @@ mod tests { }; assert_eq!(pass_through_args, Some(vec!["--workspace-root".to_string()])); } + + #[test] + fn stage_publish_parses() { + let command = parse_pm_command(&["vp", "pm", "stage", "publish", "--tag", "next"]) + .expect("stage publish should parse"); + + assert!(matches!( + command, + PackageManagerCommand::Pm(PmCommands::Stage(StageCommands::Publish { .. })) + )); + } + + #[test] + fn stage_approve_requires_stage_id() { + let error = parse_pm_command(&["vp", "pm", "stage", "approve"]) + .expect_err("stage approve without an id should fail"); + + assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn stage_list_pass_through_args_capture() { + let command = parse_pm_command(&["vp", "pm", "stage", "list", "--", "--registry", "x"]) + .expect("pass-through args should parse"); + + let PackageManagerCommand::Pm(PmCommands::Stage(StageCommands::List { + pass_through_args, + .. + })) = command + else { + panic!("expected Stage(List) variant"); + }; + assert_eq!(pass_through_args, Some(vec!["--registry".to_string(), "x".to_string()])); + } } diff --git a/crates/vite_pm_cli/src/handlers.rs b/crates/vite_pm_cli/src/handlers.rs index 22b228511c..1670c5ec4d 100644 --- a/crates/vite_pm_cli/src/handlers.rs +++ b/crates/vite_pm_cli/src/handlers.rs @@ -31,6 +31,7 @@ use vite_install::{ rebuild::RebuildCommandOptions, remove::RemoveCommandOptions, search::SearchCommandOptions, + stage::{StageCommandOptions, StageSubcommand}, token::TokenSubcommand, unlink::UnlinkCommandOptions, update::UpdateCommandOptions, @@ -42,7 +43,9 @@ use vite_install::{ use vite_path::AbsolutePath; use crate::{ - cli::{ConfigCommands, DistTagCommands, OwnerCommands, PmCommands, TokenCommands}, + cli::{ + ConfigCommands, DistTagCommands, OwnerCommands, PmCommands, StageCommands, TokenCommands, + }, error::Error, helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, }; @@ -173,6 +176,7 @@ pub async fn run_pm_subcommand( | PmCommands::Pack { .. } | PmCommands::List { .. } | PmCommands::Publish { .. } + | PmCommands::Stage(StageCommands::Publish { .. }) | PmCommands::Rebuild { .. } | PmCommands::Fund { .. } | PmCommands::Audit { .. } @@ -312,6 +316,59 @@ pub async fn run_pm_subcommand( Ok(pm.run_publish_command(&options, cwd).await?) } + PmCommands::Stage(stage_command) => { + let (subcommand, registry, pass_through_args) = match stage_command { + StageCommands::Publish { + target, + tag, + access, + otp, + dry_run, + json, + recursive, + filter, + provenance, + registry, + pass_through_args, + } => ( + StageSubcommand::Publish { + target, + tag, + access, + otp, + dry_run, + json, + recursive, + filters: filter, + provenance, + }, + registry, + pass_through_args, + ), + StageCommands::List { package, json, registry, pass_through_args } => { + (StageSubcommand::List { package, json }, registry, pass_through_args) + } + StageCommands::View { stage_id, json, registry, pass_through_args } => { + (StageSubcommand::View { stage_id, json }, registry, pass_through_args) + } + StageCommands::Download { stage_id, registry, pass_through_args } => { + (StageSubcommand::Download { stage_id }, registry, pass_through_args) + } + StageCommands::Approve { stage_id, otp, registry, pass_through_args } => { + (StageSubcommand::Approve { stage_id, otp }, registry, pass_through_args) + } + StageCommands::Reject { stage_id, otp, registry, pass_through_args } => { + (StageSubcommand::Reject { stage_id, otp }, registry, pass_through_args) + } + }; + let options = StageCommandOptions { + subcommand, + registry: registry.as_deref(), + pass_through_args: pass_through_args.as_deref(), + }; + Ok(pm.run_stage_command(&options, cwd).await?) + } + PmCommands::Owner(owner_command) => { let subcommand = match owner_command { OwnerCommands::List { package, otp } => OwnerSubcommand::List { package, otp }, diff --git a/docs/guide/install.md b/docs/guide/install.md index bc2c79b462..914cae6144 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -151,3 +151,23 @@ vp pm config get registry vp pm cache clean --force vp pm exec tsc --version ``` + +#### Staged publishing + +`vp pm stage` exposes [npm's staged publishing](https://docs.npmjs.com/staged-publishing) workflow: a build is uploaded to a staging area (no 2FA, CI-friendly), then a maintainer approves or rejects it from a trusted device (2FA). It adapts to the detected package manager. + +```bash +vp pm stage publish # upload the package to staging (no 2FA) +vp pm stage list # list staged versions +vp pm stage view # inspect a staged version +vp pm stage download # download the staged tarball +vp pm stage approve # promote to the live registry (2FA) +vp pm stage reject # discard a staged version (2FA) +``` + +- pnpm (`pnpm stage`, requires pnpm ≥ 11.3) and npm (`npm stage`, requires npm ≥ 11.15 and Node ≥ 22.14) pass through directly. +- yarn (Berry) uses its npm plugin (`yarn npm publish --staged`, `yarn npm stage …`); `view`/`download` fall back to npm. +- yarn Classic and bun have no staged-publishing support and fall back to `npm stage`. + +> [!NOTE] +> `vp pm stage` is npm staged publishing — it is unrelated to Yarn's own `yarn stage` command, which stages files for a version-control commit. diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json new file mode 100644 index 0000000000..558ae9a3f1 --- /dev/null +++ b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-pm-stage-pnpm10", + "version": "1.0.0", + "packageManager": "pnpm@10.20.0" +} diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/snap.txt new file mode 100644 index 0000000000..323e1d457b --- /dev/null +++ b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/snap.txt @@ -0,0 +1,59 @@ +> vp pm stage --help # should list the staged-publishing subcommands +Usage: vp pm stage + +Stage a package for publishing (npm staged publishing workflow) + +Commands: + publish Stage a package for publishing (no 2FA required) + list List staged versions [aliases: ls] + view Show details about a staged version + download Download the staged tarball for inspection + approve Promote a staged version to the live registry (2FA required) + reject Discard a staged version (2FA required) + +Options: + -h, --help Print help + +Documentation: https://viteplus.dev/guide/install + + +> vp pm stage publish --help # should show stage publish options +Usage: vp pm stage publish [OPTIONS] [TARBALL|FOLDER] [-- ...] + +Stage a package for publishing (no 2FA required) + +Arguments: + [TARBALL|FOLDER] Tarball or folder to stage + [PASS_THROUGH_ARGS]... Additional arguments + +Options: + --tag Publish tag + --access Access level (public/restricted) + --otp One-time password for authentication + --dry-run Preview without staging + --json Output in JSON format + -r, --recursive Stage all publishable workspace packages + --filter Filter packages in monorepo + --provenance Stage with provenance + --registry Registry URL + -h, --help Print help + +Documentation: https://viteplus.dev/guide/install + + +> vp pm stage approve --help # should show stage approve options +Usage: vp pm stage approve [OPTIONS] [-- ...] + +Promote a staged version to the live registry (2FA required) + +Arguments: + Stage ID + [PASS_THROUGH_ARGS]... Additional arguments + +Options: + --otp One-time password for authentication + --registry Registry URL + -h, --help Print help + +Documentation: https://viteplus.dev/guide/install + diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/steps.json b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/steps.json new file mode 100644 index 0000000000..b9db144e33 --- /dev/null +++ b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/steps.json @@ -0,0 +1,7 @@ +{ + "commands": [ + "vp pm stage --help # should list the staged-publishing subcommands", + "vp pm stage publish --help # should show stage publish options", + "vp pm stage approve --help # should show stage approve options" + ] +} diff --git a/rfcs/stage-command.md b/rfcs/stage-command.md new file mode 100644 index 0000000000..ae1392ce25 --- /dev/null +++ b/rfcs/stage-command.md @@ -0,0 +1,517 @@ +# RFC: Vite+ `vp pm stage` Command + +- Issue: [#1674](https://github.com/voidzero-dev/vite-plus/issues/1674) +- Status: Draft (awaiting review before implementation) + +## Summary + +Add `vp pm stage` to the `vp pm` command group. Staged publishing is npm's +new security workflow that inserts an approval step between uploading a package +and making it public: a package is uploaded to a staging area (no 2FA), and a +maintainer later approves or rejects it from a trusted device (2FA). `vp pm +stage` exposes that workflow through the unified `vp` surface and adapts to the +detected package manager (pnpm / npm / yarn / bun). + +The feature ships as a structured subcommand group mirroring the existing +`vp pm dist-tag` / `vp pm owner` / `vp pm token` subcommands: + +```bash +vp pm stage publish [TARBALL|FOLDER] # Upload a package to the staging area (no 2FA) +vp pm stage list [PACKAGE_SPEC] # List staged versions +vp pm stage view # Show details about a staged version +vp pm stage download # Download the staged tarball for inspection +vp pm stage approve # Promote a staged version to live (2FA) +vp pm stage reject # Discard a staged version (2FA) +``` + +## Motivation + +npm shipped **staged publishing** (npm CLI ≥ 11.15.0, Node ≥ 22.14.0) as a +defense against supply-chain attacks: CI can upload a build to a staging area +without holding 2FA credentials, and a human approves it later. pnpm is adding +an equivalent `pnpm stage` command, and yarn berry already exposes it through +`yarn npm publish --staged` + `yarn npm stage …`. + +Because Vite+ already normalizes the rest of the publishing surface +(`vp pm publish`, `vp pm dist-tag`, `vp pm owner`, …), the staging workflow +should be reachable the same way instead of forcing users to drop down to a +raw `pnpm`/`npm`/`yarn` invocation. Issue #1674 asks specifically for a +`vp pm stage` passthrough that "delegates correctly to the configured package +manager" and stays "aligned with the rest of the `vp pm ` surface." + +### Background: how staged publishing works + +| Step | Command | 2FA? | Notes | +| ---------- | -------------------------------------- | ------ | --------------------------------------------------------------------------------------- | +| 1. Stage | `npm stage publish` | ❌ No | Uploads the tarball to a pending staging area. Safe for CI / trusted publishers (OIDC). | +| 2. Review | `npm stage list` / `view` / `download` | ❌ No | Inspect what was staged (also visible on npmjs.com "Staged Packages" tab). | +| 3. Approve | `npm stage approve ` | ✅ Yes | Promotes to the live registry. | +| 3'. Reject | `npm stage reject ` | ✅ Yes | Discards the staged version. | + +#### Minimum versions + +The version floors differ per package manager (and npm additionally gates on +the Node.js version), which is a key input to the version-gating decision below. + +| PM | Minimum for staged publishing | +| ------------ | -------------------------------------------------------------------------------------------------- | +| npm | CLI ≥ 11.15.0 **and** Node ≥ 22.14.0 | +| pnpm | pnpm ≥ 11.3.0 (`pnpm stage` was "Added in: v11.3.0"; no separate Node floor documented) | +| yarn ≥ 2 | via the npm plugin (`yarn npm publish --staged`); registry-side, no separate yarn floor documented | +| yarn 1 / bun | unsupported | + +References: + +- npm: +- pnpm: (added in pnpm 11.3 — ) +- yarn (berry): (`--staged` flag) and `yarn npm stage …` +- bun: no staged-publishing support today (`bun publish` only) + +## Proposed Solution + +### Command surface + +`vp pm stage` is a subcommand group; a subcommand is required (bare `vp pm +stage` prints help, matching `vp pm dist-tag`). + +```bash +vp pm stage + +Subcommands: + publish Stage a package for publishing (no 2FA) + list List staged versions (alias: ls) + view Show details about a staged version + download Download the staged tarball for inspection + approve Promote a staged version to the live registry (2FA) + reject Discard a staged version (2FA) +``` + +**Examples:** + +```bash +# Stage the current package (CI-friendly, no 2FA) +vp pm stage publish +vp pm stage publish --tag next --access public + +# Stage a prebuilt tarball +vp pm stage publish ./my-pkg-1.2.3.tgz + +# Stage every publishable workspace package (pnpm) +vp pm stage publish -r +vp pm stage publish --filter "@scope/*" + +# Review what is staged +vp pm stage list +vp pm stage list my-pkg --json +vp pm stage view 1a2b3c4d +vp pm stage download 1a2b3c4d + +# Approve / reject (requires 2FA on a trusted device) +vp pm stage approve 1a2b3c4d +vp pm stage approve 1a2b3c4d --otp 123456 +vp pm stage reject 1a2b3c4d +``` + +### Flags + +Following the existing `vp pm` convention, only the common, stable flags are +modeled; anything else flows through the trailing `-- ` escape hatch +(`#[arg(last = true, allow_hyphen_values = true)]`). + +| Subcommand | Positional | Modeled flags | +| ---------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `publish` | `[TARBALL\|FOLDER]` | `--tag`, `--access `, `--otp`, `--dry-run`, `--json`, `-r/--recursive`, `--filter `, `--provenance`, `--registry` | +| `list` | `[PACKAGE_SPEC]` | `--json`, `--registry` | +| `view` | `` | `--json`, `--registry` | +| `download` | `` | `--registry` | +| `approve` | `` | `--otp`, `--registry` | +| `reject` | `` | `--otp`, `--registry` | + +`stage publish` intentionally reuses the option vocabulary of the existing +`vp pm publish` command so the two read consistently. + +### Command mapping + +This is the core of the RFC. The mapping is driven by what each package +manager actually supports. + +| `vp pm stage …` | pnpm | npm | yarn ≥ 2 (berry) | yarn 1 (classic) | bun | +| --------------- | -------------------------- | ------------------------- | ------------------------------ | ------------------------------ | ------------------------------ | +| `publish [t]` | `pnpm stage publish [t]` | `npm stage publish [t]` | `yarn npm publish --staged` | ⚠️ → `npm stage publish` | ⚠️ → `npm stage publish` | +| `list [spec]` | `pnpm stage list [spec]` | `npm stage list [spec]` | `yarn npm stage list [spec]` | ⚠️ → `npm stage list` | ⚠️ → `npm stage list` | +| `view ` | `pnpm stage view ` | `npm stage view ` | ⚠️ → `npm stage view ` | ⚠️ → `npm stage view ` | ⚠️ → `npm stage view ` | +| `download ` | `pnpm stage download ` | `npm stage download ` | ⚠️ → `npm stage download ` | ⚠️ → `npm stage download ` | ⚠️ → `npm stage download ` | +| `approve ` | `pnpm stage approve ` | `npm stage approve ` | `yarn npm stage approve ` | ⚠️ → `npm stage approve` | ⚠️ → `npm stage approve` | +| `reject ` | `pnpm stage reject ` | `npm stage reject ` | `yarn npm stage reject ` | ⚠️ → `npm stage reject` | ⚠️ → `npm stage reject` | + +⚠️ = print a `output::warn` line, then fall back to `npm stage …` (consistent +with how `vp pm dist-tag`, `vp pm fund`, `vp pm token` already fall back to npm +for registry-only features). + +### ⚠️ Critical: `yarn stage` is NOT staged publishing + +Yarn berry has a built-in command literally called **`yarn stage`** (from +`plugin-stage`), but it is **completely unrelated** to npm staged publishing — +it stages Yarn-related files (`package.json`, `.yarnrc.yml`, linker output) into +your **git/mercurial** staging area and can auto-create a release commit +(). + +> `vp pm stage` must **never** resolve to `yarn stage`. Doing so would touch the +> user's VCS index / create commits instead of publishing. + +For yarn, npm staged publishing is reached through the **npm plugin**: + +- Stage: `yarn npm publish --staged` +- Manage: `yarn npm stage list` / `yarn npm stage approve` / `yarn npm stage reject` + +yarn berry exposes only `list` / `approve` / `reject` under `yarn npm stage` +(no `view` / `download`), so those two fall back to `npm stage …`. + +### Per-package-manager behavior + +#### pnpm + +Direct passthrough: `pnpm stage [args]`. pnpm mirrors npm's subcommand +set (`publish`, `list`, `view`, `download`, `approve`, `reject`) and adds +`-r/--filter` for monorepos. `--otp` is accepted for `approve`/`reject`. + +#### npm + +Direct passthrough: `npm stage [args]`. This is the canonical +implementation; every other PM's gaps fall back here. + +#### yarn ≥ 2 (berry) + +- `publish` → `yarn npm publish --staged` (with the target dir/tarball and + `--tag`/`--access`/`--otp`/`--provenance` forwarded). +- `list` / `approve` / `reject` → `yarn npm stage `. +- `view` / `download` → not supported by yarn; warn and fall back to + `npm stage ` (registry-side operation, same data). + +#### yarn 1 (classic) + +No staged publishing. yarn classic already delegates publishing to npm in this +repo (`publish.rs`), so all `stage` subcommands warn and fall back to +`npm stage `. + +#### bun + +No staged publishing and no `bun stage`. Warn and fall back to `npm stage `, +consistent with `vp pm dist-tag`/`fund`/`token` on bun. + +## Implementation Architecture + +The current code lives in `crates/vite_pm_cli/` (clap surface + dispatch) and +`crates/vite_install/src/commands/` (per-command resolvers). The +`PackageManagerCommand`/`PmCommands` enums are shared by both the global CLI and +the local NAPI binding via `#[command(flatten)]`, so adding a variant surfaces +in both CLIs automatically. + +### 1. Clap surface — `crates/vite_pm_cli/src/cli.rs` + +Add a `Stage` variant to `PmCommands` and a `StageCommands` subcommand enum +(modeled on the existing `DistTagCommands`): + +```rust +// in enum PmCommands +/// Stage a package for publishing (npm staged publishing workflow) +#[command(subcommand)] +Stage(StageCommands), +``` + +```rust +/// Staged-publishing subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum StageCommands { + /// Stage a package for publishing (no 2FA) + Publish { + /// Tarball or folder to stage + #[arg(value_name = "TARBALL|FOLDER")] + target: Option, + #[arg(long)] tag: Option, + #[arg(long)] access: Option, + #[arg(long, value_name = "OTP")] otp: Option, + #[arg(long)] dry_run: bool, + #[arg(long)] json: bool, + #[arg(short = 'r', long)] recursive: bool, + #[arg(long, value_name = "PATTERN")] filter: Option>, + #[arg(long)] provenance: bool, + #[arg(long, value_name = "URL")] registry: Option, + #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, + }, + /// List staged versions + #[command(visible_alias = "ls")] + List { + package: Option, + #[arg(long)] json: bool, + #[arg(long, value_name = "URL")] registry: Option, + #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, + }, + /// Show details about a staged version + View { + stage_id: String, + #[arg(long)] json: bool, + #[arg(long, value_name = "URL")] registry: Option, + #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, + }, + /// Download the staged tarball for inspection + Download { + stage_id: String, + #[arg(long, value_name = "URL")] registry: Option, + #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, + }, + /// Promote a staged version to the live registry (2FA) + Approve { + stage_id: String, + #[arg(long, value_name = "OTP")] otp: Option, + #[arg(long, value_name = "URL")] registry: Option, + #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, + }, + /// Discard a staged version (2FA) + Reject { + stage_id: String, + #[arg(long, value_name = "OTP")] otp: Option, + #[arg(long, value_name = "URL")] registry: Option, + #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, + }, +} +``` + +Extend `PmCommands::is_quiet_or_machine_readable` so `--json` on +`stage publish`/`list`/`view` suppresses decorative output: + +```rust +Self::Stage(sub) => sub.is_quiet_or_machine_readable(), +``` + +with a matching `impl StageCommands` returning `*json` for `Publish`/`List`/`View`. + +### 2. Resolver — `crates/vite_install/src/commands/stage.rs` (new) + +Mirror `dist_tag.rs`: an owned `StageSubcommand` enum, a `StageCommandOptions` +struct, and `resolve_stage_command` / `run_stage_command`: + +```rust +pub enum StageSubcommand { + Publish { target: Option, tag: Option, access: Option, + otp: Option, dry_run: bool, json: bool, recursive: bool, + filters: Option>, provenance: bool }, + List { package: Option, json: bool }, + View { stage_id: String, json: bool }, + Download { stage_id: String }, + Approve { stage_id: String, otp: Option }, + Reject { stage_id: String, otp: Option }, +} + +pub struct StageCommandOptions<'a> { + pub subcommand: StageSubcommand, + pub registry: Option<&'a str>, + pub pass_through_args: Option<&'a [String]>, +} + +impl PackageManager { + pub async fn run_stage_command(&self, options: &StageCommandOptions<'_>, + cwd: impl AsRef) -> Result { /* run_command */ } + + pub fn resolve_stage_command(&self, options: &StageCommandOptions) -> ResolveCommandResult { + // match self.client { + // Pnpm => bin "pnpm", ["stage", , ...] + // Npm => bin "npm", ["stage", , ...] + // Yarn (berry) Publish => bin "yarn", ["npm", "publish", "--staged", ...] + // Yarn (berry) List/Approve/Reject => bin "yarn", ["npm", "stage", , ...] + // Yarn (berry) View/Download => warn + bin "npm", ["stage", , ...] + // Yarn (classic) / Bun => warn + bin "npm", ["stage", , ...] + // } + } +} +``` + +Register the module in `crates/vite_install/src/commands/mod.rs`: + +```rust +pub mod stage; +``` + +### 3. Handler — `crates/vite_pm_cli/src/handlers.rs` + +Import `stage::{StageCommandOptions, StageSubcommand}` and add a `PmCommands::Stage` +arm to `run_pm_subcommand`, converting the clap `StageCommands` into the owned +`StageSubcommand` (same shape as the existing `DistTag`/`Owner`/`Token` arms). + +Dispatch target selection (the `needs_project` block at the top of +`run_pm_subcommand`): only `stage publish` reads the local package and needs a +real project; `list`/`view`/`download`/`approve`/`reject` are registry-only and +can run against `build_package_manager_or_npm_default` (npm fallback when there +is no `package.json`), exactly like `vp pm view` / `vp pm dist-tag` today: + +```rust +let needs_project = matches!(command, + // …existing… + | PmCommands::Stage(StageCommands::Publish { .. }) +); +``` + +No changes are needed in `dispatch.rs` — `PackageManagerCommand::Pm` already +forwards to `handlers::run_pm_subcommand`. + +### 4. Wiring summary + +``` +vp pm stage + └─ cli.rs PmCommands::Stage(StageCommands) (shared, both CLIs) + └─ handlers.rs run_pm_subcommand → StageCommandOptions + └─ stage.rs resolve_stage_command → run_command(, args) +``` + +## Design Decisions + +1. **Structured subcommands, not a free-string passthrough.** `vp pm cache` + takes a free `subcommand: String`, but the publishing-adjacent commands + (`dist-tag`, `owner`, `token`, `config`) are modeled as typed subcommand + enums. `stage` has a small, well-defined, stable subcommand set, so typed + modeling gives proper `--help`, tab-completion, and per-subcommand flags + while staying aligned with its neighbors. (Thin passthrough is Alternative 1.) + +2. **yarn uses its native npm plugin, never `yarn stage`.** As covered above, + `yarn stage` is git staging. yarn berry's real staged-publishing path is + `yarn npm publish --staged` + `yarn npm stage …`. This respects the project's + yarn auth/registry config (`.yarnrc.yml`) rather than assuming npm is + authenticated. (See Open Question 1 for the alternative of always delegating + to npm, which would match `publish.rs`.) + +3. **bun and yarn-classic fall back to `npm stage` with a warning.** Staged + publishing is a registry-side feature; npm is the reference client and the + repo already routes registry-only features (`dist-tag`, `fund`, `token`, + `search`, `ping`) through npm. Falling back keeps the workflow usable instead + of hard-failing. + +4. **No version gating in vp.** The floors differ per tool — npm needs CLI + ≥ 11.15.0 **and** Node ≥ 22.14.0, pnpm needs ≥ 11.3.0, yarn routes through its + npm plugin — and npm uniquely also gates on Node. Rather than tracking and + chasing these across four package managers as the feature stabilizes, pass + through and let the underlying tool emit its own authoritative, + version-specific error. (`approve-builds` does gate, but that gate guards a + destructive flag shape; staging is too new/fast-moving to pin. See Open + Question 2.) + +5. **No caching.** Staging mutates registry state or queries live state; results + must never be cached. + +## Open Questions (please weigh in during review) + +1. **yarn strategy — native plugin vs. npm delegation.** + - **(A) Recommended:** map to `yarn npm publish --staged` + `yarn npm stage …` + (uses yarn's own auth/registry; most correct for yarn projects). + - **(B) Simpler/consistent:** delegate all yarn `stage` to `npm stage …`, + matching the existing `publish.rs` (yarn → npm). Lower complexity, but + `npm` may not be authenticated in a yarn-managed project. + +2. **Version gating.** Recommended: none (pass through, let the PM error) — + especially since the floors differ (npm ≥ 11.15.0 + Node ≥ 22.14.0; pnpm + ≥ 11.3.0). Do you want a friendly pre-check instead? + +3. **`view` / `download` for yarn.** yarn berry has no equivalent. Recommended: + warn + fall back to `npm stage view/download`. Alternative: treat as + unsupported and error. + +4. **`--registry` modeling.** Worth a first-class flag, or rely solely on + `-- --registry ` passthrough? (Recommended: model it, since the npm-fallback + paths benefit from threading it explicitly.) + +## Error Handling + +```bash +# Underlying tool too old (passthrough surfaces the real error) +$ vp pm stage publish +npm error staged publishing requires npm ≥ 11.15.0 +# vp exits non-zero with the tool's message + +# bun / yarn-classic +$ vp pm stage approve 1a2b3c4d +warning: bun does not support staged publishing, falling back to npm stage +… + +# Missing required stage id +$ vp pm stage approve +error: the following required arguments were not provided: + +``` + +## Testing Strategy + +### Unit tests (`crates/vite_install/src/commands/stage.rs`) + +Mirror `dist_tag.rs` / `publish.rs` mock-PM tests, asserting `bin_path` + `args` +for each (PM, subcommand) pair: + +```rust +#[test] fn pnpm_stage_publish() // pnpm, ["stage", "publish"] +#[test] fn npm_stage_publish() // npm, ["stage", "publish"] +#[test] fn yarn_berry_stage_publish_uses_npm_plugin() // yarn, ["npm","publish","--staged"] +#[test] fn yarn_berry_stage_list() // yarn, ["npm","stage","list"] +#[test] fn yarn_berry_stage_view_falls_back_to_npm() // npm, ["stage","view",""] +#[test] fn yarn1_stage_falls_back_to_npm() // npm, ["stage", ...] +#[test] fn bun_stage_falls_back_to_npm() // npm, ["stage", ...] +#[test] fn pnpm_stage_publish_recursive_filter() // ["--filter","x","stage","publish"] ordering +#[test] fn stage_approve_otp() // ["stage","approve","","--otp","123456"] +``` + +Also add a clap-parsing test in `cli.rs` (e.g. `stage approve` without an id +errors with `MissingRequiredArgument`). + +### Snap tests + +Add fixtures alongside the existing `command-publish-*` / `command-pm-*` ones: + +- Global: `packages/cli/snap-tests-global/command-pm-stage-pnpm10`, + `…-npm11`, `…-yarn4`, `…-bun` (assert the resolved command line per PM). +- Local: `packages/cli/snap-tests/command-pm-stage-pnpm10`. +- `vp pm stage --help` / `vp pm --help` snapshots will change — regenerate and + inspect the diff (snap tests can pass even when output changes). + +Run: `pnpm -F vite-plus snap-test-local command-pm-stage` and +`pnpm -F vite-plus snap-test-global command-pm-stage`, then review `git diff`. + +## Documentation + +- `docs/guide/install.md` — the `vp pm ` "Advanced" section lists + forwarded commands; add `vp pm stage` with a short staged-publishing blurb and + a pointer to npm's docs. +- Note the yarn caveat (`vp pm stage` ≠ `yarn stage`) where relevant. +- Regenerate any affected help snapshots (`command-pm-*`). + +## Compatibility Matrix + +| Subcommand | pnpm | npm | yarn ≥ 2 | yarn 1 | bun | Notes | +| ---------- | ----------------------- | ---------------------- | ------------------------------ | -------- | -------- | ---------------------- | +| `publish` | ✅ `pnpm stage publish` | ✅ `npm stage publish` | ✅ `yarn npm publish --staged` | ⚠️ → npm | ⚠️ → npm | no 2FA | +| `list` | ✅ | ✅ | ✅ `yarn npm stage list` | ⚠️ → npm | ⚠️ → npm | | +| `view` | ✅ | ✅ | ⚠️ → npm | ⚠️ → npm | ⚠️ → npm | yarn has no `view` | +| `download` | ✅ | ✅ | ⚠️ → npm | ⚠️ → npm | ⚠️ → npm | yarn has no `download` | +| `approve` | ✅ | ✅ | ✅ `yarn npm stage approve` | ⚠️ → npm | ⚠️ → npm | 2FA | +| `reject` | ✅ | ✅ | ✅ `yarn npm stage reject` | ⚠️ → npm | ⚠️ → npm | 2FA | + +✅ native · ⚠️ warn + fall back to `npm stage …` + +## Alternatives Considered + +1. **Thin free-string passthrough** (`Stage { subcommand: String, args }`, like + `vp pm cache`). Simplest to add, but loses typed `--help`/flags and makes the + yarn divergence (`yarn npm publish --staged`) impossible to express cleanly. + Rejected in favor of typed subcommands, matching `dist-tag`. + +2. **Always delegate yarn → `npm stage`** (matching `publish.rs`). Simpler, but + ignores yarn's native plugin and the project's yarn auth/registry config. + Captured as Open Question 1 rather than decided unilaterally. + +3. **Hard version-gating in vp.** Rejected: high maintenance across 4 PMs for a + fast-moving feature; the underlying tool's own error is more accurate. + +4. **Top-level `vp stage`** instead of `vp pm stage`. Rejected: staging is a + package-manager passthrough and belongs in the `vp pm` group with its + publishing siblings. + +## Backward Compatibility + +Additive only — a new `vp pm` subcommand. No existing command, config, or +caching behavior changes. From 3f7dd7f9c63f0b75e0a7c9b77a7448a390e6de83 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 30 May 2026 15:32:29 +0800 Subject: [PATCH 2/7] test(pm): pin stage snap fixture to pnpm 11 (stage needs pnpm >= 11.3) --- .../snap-tests-global/command-pm-stage-pnpm10/package.json | 5 ----- .../snap-tests-global/command-pm-stage-pnpm11/package.json | 5 +++++ .../snap.txt | 0 .../steps.json | 0 4 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json create mode 100644 packages/cli/snap-tests-global/command-pm-stage-pnpm11/package.json rename packages/cli/snap-tests-global/{command-pm-stage-pnpm10 => command-pm-stage-pnpm11}/snap.txt (100%) rename packages/cli/snap-tests-global/{command-pm-stage-pnpm10 => command-pm-stage-pnpm11}/steps.json (100%) diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json b/packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json deleted file mode 100644 index 558ae9a3f1..0000000000 --- a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "command-pm-stage-pnpm10", - "version": "1.0.0", - "packageManager": "pnpm@10.20.0" -} diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm11/package.json b/packages/cli/snap-tests-global/command-pm-stage-pnpm11/package.json new file mode 100644 index 0000000000..834cb357f3 --- /dev/null +++ b/packages/cli/snap-tests-global/command-pm-stage-pnpm11/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-pm-stage-pnpm11", + "version": "1.0.0", + "packageManager": "pnpm@11.5.0" +} diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-pm-stage-pnpm11/snap.txt similarity index 100% rename from packages/cli/snap-tests-global/command-pm-stage-pnpm10/snap.txt rename to packages/cli/snap-tests-global/command-pm-stage-pnpm11/snap.txt diff --git a/packages/cli/snap-tests-global/command-pm-stage-pnpm10/steps.json b/packages/cli/snap-tests-global/command-pm-stage-pnpm11/steps.json similarity index 100% rename from packages/cli/snap-tests-global/command-pm-stage-pnpm10/steps.json rename to packages/cli/snap-tests-global/command-pm-stage-pnpm11/steps.json From 7c1f4d8d5e8bbc7ac6430097bcc8af084c2cc3c3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 30 May 2026 15:50:34 +0800 Subject: [PATCH 3/7] fix(pm): correct vp pm stage yarn berry flag handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review findings for `vp pm stage`: - Drop `--registry` on yarn's npm-plugin paths (`yarn npm publish/stage` reject it; yarn resolves the registry from .yarnrc.yml) instead of forwarding a flag that makes yarn abort. - Forward `--dry-run` and `--json` on yarn berry publish — `yarn npm publish` supports both, so warning-and-dropping made a requested dry-run perform a real stage and broke --json consumers. - Route `vp pm stage publish ` through npm for yarn berry, since `yarn npm publish` has no target argument and would otherwise stage the workspace package instead of the requested artifact. - Regenerate the cli-helper-message snapshot to include the new stage row. Adds yarn berry tests for dry-run/json/provenance forwarding, the target -> npm fallback, and --registry being dropped. --- crates/vite_install/src/commands/stage.rs | 100 ++++++++++++++++-- .../cli-helper-message/snap.txt | 1 + 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/crates/vite_install/src/commands/stage.rs b/crates/vite_install/src/commands/stage.rs index 15f4e8eb29..59cfc1cc3d 100644 --- a/crates/vite_install/src/commands/stage.rs +++ b/crates/vite_install/src/commands/stage.rs @@ -106,8 +106,20 @@ impl PackageManager { append_npm_stage(&mut args, &options.subcommand); } else { match &options.subcommand { + StageSubcommand::Publish { target: Some(_), .. } => { + // `yarn npm publish` has no target argument; it always + // stages the active workspace. To honor an explicit + // tarball/folder, stage it through npm instead (npm + // staged publishing accepts a target), matching how + // `vp pm publish` delegates yarn -> npm. + output::warn( + "yarn cannot stage a prebuilt tarball or folder; using npm stage publish for the given target", + ); + bin_name = "npm".into(); + append_npm_stage(&mut args, &options.subcommand); + } StageSubcommand::Publish { .. } => { - // yarn berry stages via `yarn npm publish --staged`. + // yarn berry stages the workspace via `yarn npm publish --staged`. bin_name = "yarn".into(); append_yarn_publish_staged(&mut args, &options.subcommand); } @@ -136,10 +148,19 @@ impl PackageManager { } } - // `--registry` applies to every variant and is forwarded as-is. + // `--registry` is forwarded to npm/pnpm, which accept it. yarn's npm + // plugin (`yarn npm publish`/`yarn npm stage`) does not take a + // `--registry` flag — it resolves the registry from `.yarnrc.yml` — so + // forwarding it would make yarn abort with an unknown-option error. if let Some(registry) = options.registry { - args.push("--registry".into()); - args.push(registry.to_string()); + if bin_name == "yarn" { + output::warn( + "--registry is not supported by yarn's npm plugin (set the registry in .yarnrc.yml), ignoring flag", + ); + } else { + args.push("--registry".into()); + args.push(registry.to_string()); + } } // Add pass-through args. @@ -249,10 +270,13 @@ fn append_stage_subcommand( } /// Build `yarn npm publish --staged …`. yarn berry's npm plugin stages via the -/// publish command; only the flags yarn supports are forwarded. +/// publish command; `--tag`/`--access`/`--otp`/`--provenance`/`--dry-run`/`--json` +/// are all forwarded (yarn supports them). The `target` positional is handled by +/// the caller (routed to npm) and never reaches here, and `--recursive`/`--filter` +/// have no `yarn npm publish` equivalent so they are warned and dropped. fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcommand) { let StageSubcommand::Publish { - target, + target: _, tag, access, otp, @@ -270,9 +294,6 @@ fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcomma args.push("publish".into()); args.push("--staged".into()); - if target.is_some() { - output::warn("yarn npm publish does not accept a target path, ignoring it"); - } if let Some(tag) = tag { args.push("--tag".into()); args.push(tag.clone()); @@ -289,10 +310,10 @@ fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcomma args.push("--provenance".into()); } if *dry_run { - output::warn("--dry-run is not supported by yarn npm publish, ignoring flag"); + args.push("--dry-run".into()); } if *json { - output::warn("--json is not supported by yarn npm publish, ignoring flag"); + args.push("--json".into()); } if *recursive { output::warn("--recursive is not supported by yarn npm publish, ignoring flag"); @@ -498,6 +519,63 @@ mod tests { assert_eq!(result.args, vec!["npm", "publish", "--staged", "--tag", "next"]); } + #[test] + fn test_yarn_berry_stage_publish_forwards_dry_run_json_provenance() { + // `yarn npm publish` supports --dry-run, --json, and --provenance, so + // they must be forwarded (not warned-and-dropped). + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::Publish { + target: None, + tag: None, + access: None, + otp: None, + dry_run: true, + json: true, + recursive: false, + filters: None, + provenance: true, + })); + assert_eq!(result.bin_path, "yarn"); + assert_eq!( + result.args, + vec!["npm", "publish", "--staged", "--provenance", "--dry-run", "--json"] + ); + } + + #[test] + fn test_yarn_berry_stage_publish_with_target_falls_back_to_npm() { + // `yarn npm publish` has no target argument; honor the explicit tarball + // via npm instead of silently staging the workspace package. + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_stage_command(&opts(StageSubcommand::Publish { + target: Some("./pkg.tgz".into()), + tag: None, + access: None, + otp: None, + dry_run: false, + json: false, + recursive: false, + filters: None, + provenance: false, + })); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["stage", "publish", "./pkg.tgz"]); + } + + #[test] + fn test_yarn_berry_stage_registry_dropped() { + // yarn's npm plugin does not accept --registry; it must be dropped (the + // resolver warns) rather than forwarded into a yarn command that errors. + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_stage_command(&StageCommandOptions { + subcommand: StageSubcommand::List { package: None, json: false }, + registry: Some("https://registry.example.com"), + pass_through_args: None, + }); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["npm", "stage", "list"]); + } + #[test] fn test_yarn_berry_stage_list() { let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index 275b16e0fe..91b0d6159e 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -321,6 +321,7 @@ Commands: list List installed packages [aliases: ls] view View package information from the registry [aliases: info, show] publish Publish package to registry + stage Stage a package for publishing (npm staged publishing workflow) owner Manage package owners [aliases: author] cache Manage package cache config Manage package manager configuration [aliases: c] From 7bfefb670f5b0036c530eb0699318b6ddff9f364 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 30 May 2026 15:56:06 +0800 Subject: [PATCH 4/7] refactor(pm): dedupe vp pm stage flag forwarding, reuse is_yarn_berry Follow-up cleanup from code review (no behavior change): - Extract push_publish_flags() shared by the npm/pnpm stage-publish path and the yarn `npm publish --staged` path, removing the duplicated tag/access/otp/dry-run/json/provenance forwarding that had drifted. - Reuse PackageManager::is_yarn_berry() (promoted to pub(crate)) instead of re-spelling `version.starts_with("1.")` inline. - Drop the misleading `support_workspace` bool from append_stage_subcommand; pnpm's --filter/--recursive are emitted together in the pnpm arm. Rename warn_workspace_unsupported -> warn_npm_workspace_unsupported and drop its unused client param. --- crates/vite_install/src/commands/install.rs | 2 +- crates/vite_install/src/commands/stage.rs | 119 +++++++++----------- 2 files changed, 57 insertions(+), 64 deletions(-) diff --git a/crates/vite_install/src/commands/install.rs b/crates/vite_install/src/commands/install.rs index 282b97ded0..de591a3682 100644 --- a/crates/vite_install/src/commands/install.rs +++ b/crates/vite_install/src/commands/install.rs @@ -366,7 +366,7 @@ impl PackageManager { } /// Check if yarn version is Berry (v2+) - fn is_yarn_berry(&self) -> bool { + pub(crate) fn is_yarn_berry(&self) -> bool { !self.version.starts_with("1.") } } diff --git a/crates/vite_install/src/commands/stage.rs b/crates/vite_install/src/commands/stage.rs index 59cfc1cc3d..d8e11a31c9 100644 --- a/crates/vite_install/src/commands/stage.rs +++ b/crates/vite_install/src/commands/stage.rs @@ -81,7 +81,9 @@ impl PackageManager { PackageManagerType::Pnpm => { bin_name = "pnpm".into(); - // pnpm: --filter must come before the command. + // pnpm: --filter must come before the command. `--filter` and + // `--recursive` are pnpm-publish-only workspace flags, so both + // live here rather than in the shared subcommand builder. if let StageSubcommand::Publish { filters: Some(filters), .. } = &options.subcommand { for filter in filters { @@ -91,20 +93,18 @@ impl PackageManager { } args.push("stage".into()); - append_stage_subcommand(&mut args, &options.subcommand, true); + append_stage_subcommand(&mut args, &options.subcommand); + + if let StageSubcommand::Publish { recursive: true, .. } = &options.subcommand { + args.push("--recursive".into()); + } } PackageManagerType::Npm => { bin_name = "npm".into(); append_npm_stage(&mut args, &options.subcommand); } PackageManagerType::Yarn => { - if self.version.starts_with("1.") { - output::warn( - "yarn 1 does not support staged publishing, falling back to npm stage", - ); - bin_name = "npm".into(); - append_npm_stage(&mut args, &options.subcommand); - } else { + if self.is_yarn_berry() { match &options.subcommand { StageSubcommand::Publish { target: Some(_), .. } => { // `yarn npm publish` has no target argument; it always @@ -129,7 +129,7 @@ impl PackageManager { bin_name = "yarn".into(); args.push("npm".into()); args.push("stage".into()); - append_stage_subcommand(&mut args, &options.subcommand, false); + append_stage_subcommand(&mut args, &options.subcommand); } StageSubcommand::View { .. } | StageSubcommand::Download { .. } => { output::warn( @@ -139,6 +139,12 @@ impl PackageManager { append_npm_stage(&mut args, &options.subcommand); } } + } else { + output::warn( + "yarn 1 does not support staged publishing, falling back to npm stage", + ); + bin_name = "npm".into(); + append_npm_stage(&mut args, &options.subcommand); } } PackageManagerType::Bun => { @@ -175,20 +181,15 @@ impl PackageManager { /// Build the `npm stage …` argument list (also used as the fallback path for /// yarn 1, bun, and yarn berry's unsupported `view`/`download`). fn append_npm_stage(args: &mut Vec, subcommand: &StageSubcommand) { - warn_workspace_unsupported(subcommand, "npm staged publishing"); + warn_npm_workspace_unsupported(subcommand); args.push("stage".into()); - append_stage_subcommand(args, subcommand, false); + append_stage_subcommand(args, subcommand); } -/// Append the ` [args]` portion in npm/pnpm style. -/// -/// `support_workspace` controls whether pnpm's `--recursive` flag is emitted; -/// `--filter` is handled by the caller as a pnpm prefix. -fn append_stage_subcommand( - args: &mut Vec, - subcommand: &StageSubcommand, - support_workspace: bool, -) { +/// Append the ` [args]` portion shared by the npm/pnpm `stage` and +/// yarn `npm stage` paths. The pnpm-publish-only workspace flags +/// (`--filter`/`--recursive`) are emitted by the pnpm caller, not here. +fn append_stage_subcommand(args: &mut Vec, subcommand: &StageSubcommand) { match subcommand { StageSubcommand::Publish { target, @@ -197,38 +198,15 @@ fn append_stage_subcommand( otp, dry_run, json, - recursive, provenance, + recursive: _, filters: _, } => { args.push("publish".into()); if let Some(target) = target { args.push(target.clone()); } - if let Some(tag) = tag { - args.push("--tag".into()); - args.push(tag.clone()); - } - if let Some(access) = access { - args.push("--access".into()); - args.push(access.clone()); - } - if let Some(otp) = otp { - args.push("--otp".into()); - args.push(otp.clone()); - } - if *dry_run { - args.push("--dry-run".into()); - } - if *json { - args.push("--json".into()); - } - if support_workspace && *recursive { - args.push("--recursive".into()); - } - if *provenance { - args.push("--provenance".into()); - } + push_publish_flags(args, tag, access, otp, *dry_run, *json, *provenance); } StageSubcommand::List { package, json } => { args.push("list".into()); @@ -276,7 +254,6 @@ fn append_stage_subcommand( /// have no `yarn npm publish` equivalent so they are warned and dropped. fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcommand) { let StageSubcommand::Publish { - target: _, tag, access, otp, @@ -284,6 +261,7 @@ fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcomma json, recursive, filters, + target: _, provenance, } = subcommand else { @@ -293,7 +271,28 @@ fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcomma args.push("npm".into()); args.push("publish".into()); args.push("--staged".into()); + push_publish_flags(args, tag, access, otp, *dry_run, *json, *provenance); + if *recursive { + output::warn("--recursive is not supported by yarn npm publish, ignoring flag"); + } + if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { + output::warn("--filter is not supported by yarn npm publish, ignoring flag"); + } +} + +/// Forward the publish flags common to the npm/pnpm `stage publish` path and the +/// yarn `npm publish --staged` path. Flag order is not significant to any of the +/// package managers, so a single canonical order is used. +fn push_publish_flags( + args: &mut Vec, + tag: &Option, + access: &Option, + otp: &Option, + dry_run: bool, + json: bool, + provenance: bool, +) { if let Some(tag) = tag { args.push("--tag".into()); args.push(tag.clone()); @@ -306,32 +305,26 @@ fn append_yarn_publish_staged(args: &mut Vec, subcommand: &StageSubcomma args.push("--otp".into()); args.push(otp.clone()); } - if *provenance { - args.push("--provenance".into()); - } - if *dry_run { + if dry_run { args.push("--dry-run".into()); } - if *json { + if json { args.push("--json".into()); } - if *recursive { - output::warn("--recursive is not supported by yarn npm publish, ignoring flag"); - } - if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { - output::warn("--filter is not supported by yarn npm publish, ignoring flag"); + if provenance { + args.push("--provenance".into()); } } -/// Warn about workspace flags that the given client cannot honor for staged -/// publishing (only `publish` carries them). -fn warn_workspace_unsupported(subcommand: &StageSubcommand, client: &str) { +/// Warn about the workspace flags (`--recursive`/`--filter`) that npm staged +/// publishing cannot honor (only `publish` carries them). +fn warn_npm_workspace_unsupported(subcommand: &StageSubcommand) { if let StageSubcommand::Publish { recursive, filters, .. } = subcommand { if *recursive { - output::warn(&format!("--recursive is not supported by {client}, ignoring flag")); + output::warn("--recursive is not supported by npm staged publishing, ignoring flag"); } if filters.as_ref().is_some_and(|filters| !filters.is_empty()) { - output::warn(&format!("--filter is not supported by {client}, ignoring flag")); + output::warn("--filter is not supported by npm staged publishing, ignoring flag"); } } } @@ -538,7 +531,7 @@ mod tests { assert_eq!(result.bin_path, "yarn"); assert_eq!( result.args, - vec!["npm", "publish", "--staged", "--provenance", "--dry-run", "--json"] + vec!["npm", "publish", "--staged", "--dry-run", "--json", "--provenance"] ); } From 83ef300eb6176bec2ebe2b198f8a8b5830a1e278 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 1 Jun 2026 11:03:41 +0800 Subject: [PATCH 5/7] docs(pm): drop yarn-stage note, remove em dashes from stage docs/comments Address PR review: remove the note contrasting vp pm stage with yarn's own `yarn stage` command. Also replace em dashes with commas/parentheses in the stage docs section and resolver comments, per project style. --- crates/vite_install/src/commands/stage.rs | 6 +++--- docs/guide/install.md | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/vite_install/src/commands/stage.rs b/crates/vite_install/src/commands/stage.rs index d8e11a31c9..cbd88079cf 100644 --- a/crates/vite_install/src/commands/stage.rs +++ b/crates/vite_install/src/commands/stage.rs @@ -68,8 +68,8 @@ impl PackageManager { /// falling back to npm for `view`/`download` which yarn does not expose. /// yarn 1 and bun have no staged-publishing support and fall back to npm. /// - /// Note: `yarn stage` is git/VCS staging, not publishing — it is never used - /// here. + /// Note: `yarn stage` is git/VCS staging, not publishing, so it is never + /// used here. #[must_use] pub fn resolve_stage_command(&self, options: &StageCommandOptions) -> ResolveCommandResult { let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); @@ -156,7 +156,7 @@ impl PackageManager { // `--registry` is forwarded to npm/pnpm, which accept it. yarn's npm // plugin (`yarn npm publish`/`yarn npm stage`) does not take a - // `--registry` flag — it resolves the registry from `.yarnrc.yml` — so + // `--registry` flag (it resolves the registry from `.yarnrc.yml`), so // forwarding it would make yarn abort with an unknown-option error. if let Some(registry) = options.registry { if bin_name == "yarn" { diff --git a/docs/guide/install.md b/docs/guide/install.md index 914cae6144..6a28727201 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -168,6 +168,3 @@ vp pm stage reject # discard a staged version (2FA) - pnpm (`pnpm stage`, requires pnpm ≥ 11.3) and npm (`npm stage`, requires npm ≥ 11.15 and Node ≥ 22.14) pass through directly. - yarn (Berry) uses its npm plugin (`yarn npm publish --staged`, `yarn npm stage …`); `view`/`download` fall back to npm. - yarn Classic and bun have no staged-publishing support and fall back to `npm stage`. - -> [!NOTE] -> `vp pm stage` is npm staged publishing — it is unrelated to Yarn's own `yarn stage` command, which stages files for a version-control commit. From ddf1ada0d782d4b7ee16016823342d8e7942d199 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 1 Jun 2026 11:06:59 +0800 Subject: [PATCH 6/7] docs(rfc): remove em dashes from stage RFC --- rfcs/stage-command.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/rfcs/stage-command.md b/rfcs/stage-command.md index ae1392ce25..64ae987bec 100644 --- a/rfcs/stage-command.md +++ b/rfcs/stage-command.md @@ -63,7 +63,7 @@ the Node.js version), which is a key input to the version-gating decision below. References: - npm: -- pnpm: (added in pnpm 11.3 — ) +- pnpm: (added in pnpm 11.3, see ) - yarn (berry): (`--staged` flag) and `yarn npm stage …` - bun: no staged-publishing support today (`bun publish` only) @@ -151,7 +151,7 @@ for registry-only features). ### ⚠️ Critical: `yarn stage` is NOT staged publishing Yarn berry has a built-in command literally called **`yarn stage`** (from -`plugin-stage`), but it is **completely unrelated** to npm staged publishing — +`plugin-stage`), but it is **completely unrelated** to npm staged publishing: it stages Yarn-related files (`package.json`, `.yarnrc.yml`, linker output) into your **git/mercurial** staging area and can auto-create a release commit (). @@ -207,7 +207,7 @@ The current code lives in `crates/vite_pm_cli/` (clap surface + dispatch) and the local NAPI binding via `#[command(flatten)]`, so adding a variant surfaces in both CLIs automatically. -### 1. Clap surface — `crates/vite_pm_cli/src/cli.rs` +### 1. Clap surface: `crates/vite_pm_cli/src/cli.rs` Add a `Stage` variant to `PmCommands` and a `StageCommands` subcommand enum (modeled on the existing `DistTagCommands`): @@ -286,7 +286,7 @@ Self::Stage(sub) => sub.is_quiet_or_machine_readable(), with a matching `impl StageCommands` returning `*json` for `Publish`/`List`/`View`. -### 2. Resolver — `crates/vite_install/src/commands/stage.rs` (new) +### 2. Resolver: `crates/vite_install/src/commands/stage.rs` (new) Mirror `dist_tag.rs`: an owned `StageSubcommand` enum, a `StageCommandOptions` struct, and `resolve_stage_command` / `run_stage_command`: @@ -332,7 +332,7 @@ Register the module in `crates/vite_install/src/commands/mod.rs`: pub mod stage; ``` -### 3. Handler — `crates/vite_pm_cli/src/handlers.rs` +### 3. Handler: `crates/vite_pm_cli/src/handlers.rs` Import `stage::{StageCommandOptions, StageSubcommand}` and add a `PmCommands::Stage` arm to `run_pm_subcommand`, converting the clap `StageCommands` into the owned @@ -351,7 +351,7 @@ let needs_project = matches!(command, ); ``` -No changes are needed in `dispatch.rs` — `PackageManagerCommand::Pm` already +No changes are needed in `dispatch.rs`; `PackageManagerCommand::Pm` already forwards to `handlers::run_pm_subcommand`. ### 4. Wiring summary @@ -385,9 +385,9 @@ vp pm stage `search`, `ping`) through npm. Falling back keeps the workflow usable instead of hard-failing. -4. **No version gating in vp.** The floors differ per tool — npm needs CLI +4. **No version gating in vp.** The floors differ per tool: npm needs CLI ≥ 11.15.0 **and** Node ≥ 22.14.0, pnpm needs ≥ 11.3.0, yarn routes through its - npm plugin — and npm uniquely also gates on Node. Rather than tracking and + npm plugin, and npm uniquely also gates on Node. Rather than tracking and chasing these across four package managers as the feature stabilizes, pass through and let the underlying tool emit its own authoritative, version-specific error. (`approve-builds` does gate, but that gate guards a @@ -399,14 +399,14 @@ vp pm stage ## Open Questions (please weigh in during review) -1. **yarn strategy — native plugin vs. npm delegation.** +1. **yarn strategy: native plugin vs. npm delegation.** - **(A) Recommended:** map to `yarn npm publish --staged` + `yarn npm stage …` (uses yarn's own auth/registry; most correct for yarn projects). - **(B) Simpler/consistent:** delegate all yarn `stage` to `npm stage …`, matching the existing `publish.rs` (yarn → npm). Lower complexity, but `npm` may not be authenticated in a yarn-managed project. -2. **Version gating.** Recommended: none (pass through, let the PM error) — +2. **Version gating.** Recommended: none (pass through, let the PM error), especially since the floors differ (npm ≥ 11.15.0 + Node ≥ 22.14.0; pnpm ≥ 11.3.0). Do you want a friendly pre-check instead? @@ -466,7 +466,7 @@ Add fixtures alongside the existing `command-publish-*` / `command-pm-*` ones: - Global: `packages/cli/snap-tests-global/command-pm-stage-pnpm10`, `…-npm11`, `…-yarn4`, `…-bun` (assert the resolved command line per PM). - Local: `packages/cli/snap-tests/command-pm-stage-pnpm10`. -- `vp pm stage --help` / `vp pm --help` snapshots will change — regenerate and +- `vp pm stage --help` / `vp pm --help` snapshots will change, so regenerate and inspect the diff (snap tests can pass even when output changes). Run: `pnpm -F vite-plus snap-test-local command-pm-stage` and @@ -474,7 +474,7 @@ Run: `pnpm -F vite-plus snap-test-local command-pm-stage` and ## Documentation -- `docs/guide/install.md` — the `vp pm ` "Advanced" section lists +- `docs/guide/install.md`: the `vp pm ` "Advanced" section lists forwarded commands; add `vp pm stage` with a short staged-publishing blurb and a pointer to npm's docs. - Note the yarn caveat (`vp pm stage` ≠ `yarn stage`) where relevant. @@ -513,5 +513,5 @@ Run: `pnpm -F vite-plus snap-test-local command-pm-stage` and ## Backward Compatibility -Additive only — a new `vp pm` subcommand. No existing command, config, or +Additive only: a new `vp pm` subcommand. No existing command, config, or caching behavior changes. From c40fc54beec97cd425bd2e7cacc168a8633e2986 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 1 Jun 2026 11:22:44 +0800 Subject: [PATCH 7/7] docs(rfc): mark stage RFC as implemented --- rfcs/stage-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/stage-command.md b/rfcs/stage-command.md index 64ae987bec..6ad1235ad1 100644 --- a/rfcs/stage-command.md +++ b/rfcs/stage-command.md @@ -1,7 +1,7 @@ # RFC: Vite+ `vp pm stage` Command - Issue: [#1674](https://github.com/voidzero-dev/vite-plus/issues/1674) -- Status: Draft (awaiting review before implementation) +- Status: Implemented in [#1715](https://github.com/voidzero-dev/vite-plus/pull/1715) ## Summary