diff --git a/packages/zpm/src/commands/mod.rs b/packages/zpm/src/commands/mod.rs index 5c652745..668f5718 100644 --- a/packages/zpm/src/commands/mod.rs +++ b/packages/zpm/src/commands/mod.rs @@ -80,6 +80,9 @@ pub enum YarnCli { LogoutAll(npm::logout_all::LogoutAll), Logout(npm::logout::Logout), Publish(npm::publish::Publish), + NpmStageList(npm::stage::list::List), + NpmStageApprove(npm::stage::approve::Approve), + NpmStageReject(npm::stage::reject::Reject), Whoami(npm::whoami::Whoami), VersionApply(version::apply::VersionApply), diff --git a/packages/zpm/src/commands/npm/mod.rs b/packages/zpm/src/commands/npm/mod.rs index 8e65c48d..930b8c8c 100644 --- a/packages/zpm/src/commands/npm/mod.rs +++ b/packages/zpm/src/commands/npm/mod.rs @@ -4,4 +4,5 @@ pub mod login; pub mod logout_all; pub mod logout; pub mod publish; +pub mod stage; pub mod whoami; diff --git a/packages/zpm/src/commands/npm/publish.rs b/packages/zpm/src/commands/npm/publish.rs index 26384f47..a2c0ef41 100644 --- a/packages/zpm/src/commands/npm/publish.rs +++ b/packages/zpm/src/commands/npm/publish.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, env::VarError}; use clipanion::cli; use http::StatusCode; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use zpm_macro_enum::zpm_enum; use zpm_parsers::{JsonDocument, RawJsonOwnedValue}; use zpm_utils::{DataType, IoResultExt, Provider, Sha1, Sha512, ToFileString, ToHumanString, is_ci}; @@ -35,6 +35,9 @@ enum NpmPublishAccess { /// By default, attempting to publish a version that already exists on the registry is an error. Use `--tolerate-republish` to check first and skip /// the upload when the same version is already known by the registry. /// +/// With `--staged`, the package is staged for later approval without requiring two-factor authentication. Use +/// `yarn npm stage list`, `yarn npm stage approve`, and `yarn npm stage reject` to manage staged packages. +/// #[cli::command] #[cli::path("npm", "publish")] #[cli::category("Npm-related commands")] @@ -63,6 +66,10 @@ pub struct Publish { #[cli::option("--dry-run", default = false)] dry_run: bool, + /// Stage the package for later approval instead of publishing it immediately + #[cli::option("--staged", default = false)] + staged: bool, + /// Output the result as JSON #[cli::option("--json", default = false)] json: bool, @@ -109,10 +116,7 @@ impl Publish { }; let registry_base - = match pack_result.pack_manifest.publish_config.registry.as_deref() { - Some(registry) => registry.strip_suffix('/').unwrap_or(registry).to_string(), - None => http_npm::get_registry_for_ident(&project.config, Some(ident), true)?.to_string(), - }; + = http_npm::get_publish_registry(&project.config, &pack_result.pack_manifest)?.to_string(); let manifest_access = pack_result.pack_manifest.publish_config.access.map(NpmPublishAccess::from); let configured_access @@ -122,6 +126,10 @@ impl Publish { .or(manifest_access.as_ref()) .or(configured_access.as_ref()); + if self.staged && !self.json { + println!("Staging to {} with tag {}", DataType::Url.colorize(registry_base.as_str()), DataType::Code.colorize(&self.tag)); + } + if self.tolerate_republish { let check_url = npm::registry_url_for_one_version(&ident, &version); @@ -296,6 +304,8 @@ impl Publish { let registry_url = npm::registry_url_for_all_versions(&ident); + let mut stage_id = None; + if !self.dry_run { let authorization = http_npm::get_authorization(&http_npm::GetAuthorizationOptions { @@ -307,16 +317,44 @@ impl Publish { allow_oidc: true, }).await?; - http_npm::put(&NpmHttpParams { - http_client: &project.http_client, - registry: registry_base.as_str(), - path: ®istry_url, - authorization: authorization.as_deref(), - otp: self.otp.as_ref().map(|s| s.as_str()), - }, publish_body).await?; + if self.staged { + let response = http_npm::post(&NpmHttpParams { + http_client: &project.http_client, + registry: registry_base.as_str(), + path: &format!("/-/stage/package{}", registry_url), + authorization: authorization.as_deref(), + otp: None, + }, publish_body).await?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct StageResponse { + stage_id: Option, + } + + let response: StageResponse + = JsonDocument::hydrate_from_slice(&response.bytes().await?)?; + + stage_id = response.stage_id; + } else { + http_npm::put(&NpmHttpParams { + http_client: &project.http_client, + registry: registry_base.as_str(), + path: ®istry_url, + authorization: authorization.as_deref(), + otp: self.otp.as_deref(), + }, publish_body).await?; + } } - let message = if self.dry_run { + let message = if self.staged && self.dry_run { + "Package archive not staged (dry run)".to_string() + } else if self.staged { + match stage_id.as_deref() { + Some(stage_id) => format!("Package archive staged for approval (run {} to approve)", DataType::Code.colorize(&format!("yarn npm stage approve {}", stage_id))), + None => "Package archive staged for approval".to_string(), + } + } else if self.dry_run { format!("Package would be published to {} with tag {}", DataType::Url.colorize(registry_base.as_str()), DataType::Code.colorize(&self.tag)) } else { format!("Published package to {} with tag {}", DataType::Url.colorize(registry_base.as_str()), DataType::Code.colorize(&self.tag)) @@ -333,7 +371,10 @@ impl Publish { files: Vec, access: Option<&'a NpmPublishAccess>, dry_run: bool, + staged: bool, published: bool, + #[serde(skip_serializing_if = "Option::is_none")] + stage_id: Option<&'a str>, message: String, provenance: bool, } @@ -346,7 +387,9 @@ impl Publish { files: pack_result.pack_list.iter().map(|p| p.to_file_string()).collect(), access: publish_access, dry_run: self.dry_run, - published: !self.dry_run, + staged: self.staged, + published: !self.dry_run && !self.staged, + stage_id: stage_id.as_deref(), message: message.clone(), provenance: provenance, }; diff --git a/packages/zpm/src/commands/npm/stage/approve.rs b/packages/zpm/src/commands/npm/stage/approve.rs new file mode 100644 index 00000000..763b3369 --- /dev/null +++ b/packages/zpm/src/commands/npm/stage/approve.rs @@ -0,0 +1,67 @@ +use clipanion::cli; +use zpm_utils::DataType; + +use crate::{ + error::Error, + http_npm::{self, NpmHttpParams}, + project::Project, + report::{with_report_result, StreamReport, StreamReportConfig}, +}; + +use super::{registry_auth, StageId}; + +/// Approve staged package versions for publishing. +/// +/// This command approves one or more staged package versions on the active workspace's publish registry. +/// Pass multiple stage IDs to approve them in order. All IDs are validated before any requests are sent. +/// If an approval fails, the command stops; earlier successful approvals remain published. +/// If the registry requires two-factor authentication, use `--otp` or enter the code when prompted. +/// +#[cli::command] +#[cli::path("npm", "stage", "approve")] +#[cli::category("Npm-related commands")] +pub struct Approve { + /// The UUID of the staged package version + stage_id: StageId, + + /// Additional staged package version UUIDs to approve + additional_stage_ids: Vec, + + /// One-time password to use when the registry requires two-factor authentication + #[cli::option("--otp")] + otp: Option, +} + +impl Approve { + pub async fn execute(&self) -> Result<(), Error> { + let project + = Project::new(None).await?; + + let report + = StreamReport::new(StreamReportConfig::from_config(&project.config)); + + with_report_result(report, async { + let (registry, authorization) + = registry_auth(&project).await?; + + for stage_id in std::iter::once(&self.stage_id).chain(&self.additional_stage_ids) { + let pretty_stage_id + = DataType::Code.colorize(&stage_id.0); + + println!("Approving staged package {}...", pretty_stage_id); + + http_npm::post(&NpmHttpParams { + http_client: &project.http_client, + registry: ®istry, + path: &format!("/-/stage/{}/approve", stage_id.0), + authorization: authorization.as_deref(), + otp: self.otp.as_deref(), + }, "null".to_string()).await?; + + println!("Staged package {} approved and published successfully.", pretty_stage_id); + } + + Ok(()) + }).await + } +} diff --git a/packages/zpm/src/commands/npm/stage/list.rs b/packages/zpm/src/commands/npm/stage/list.rs new file mode 100644 index 00000000..86f23f89 --- /dev/null +++ b/packages/zpm/src/commands/npm/stage/list.rs @@ -0,0 +1,152 @@ +use clipanion::cli; +use indexmap::IndexMap; +use serde::Deserialize; +use zpm_parsers::JsonDocument; +use zpm_primitives::{Descriptor, DescriptorResolution, Locator}; +use zpm_utils::{tree, AbstractValue, DataType, FromFileString, RawString}; + +use crate::{ + error::Error, + http_npm::{self, NpmHttpParams}, + project::Project, +}; + +use super::registry_auth; + +/// List staged package versions awaiting approval. +/// +/// This command lists all staged versions on the active workspace's publish registry, honoring `publishConfig.registry`, +/// scoped registry settings, and matching package rules, just like `yarn npm publish --staged`. +/// When a package name is provided, only staged versions of that package are listed. +/// +#[cli::command] +#[cli::path("npm", "stage", "list")] +#[cli::category("Npm-related commands")] +pub struct List { + /// Format the output as an NDJSON stream + #[cli::option("--json", default = false)] + json: bool, + + /// Only list staged versions of this package + package: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct StagedPackage { + id: String, + package_name: String, + version: String, + tag: String, + created_at: String, +} + +#[derive(Deserialize)] +struct StageListResponse { + items: Vec, + total: usize, +} + +impl List { + pub async fn execute(&self) -> Result<(), Error> { + let project + = Project::new(None).await?; + + let (registry, authorization) + = registry_auth(&project).await?; + + let mut items = Vec::new(); + let mut page = 0; + let per_page = 100; + + loop { + let mut query + = url::form_urlencoded::Serializer::new(String::new()); + + query.append_pair("page", &page.to_string()); + query.append_pair("perPage", &per_page.to_string()); + + if let Some(package) = &self.package { + query.append_pair("package", package); + } + + let response = http_npm::get(&NpmHttpParams { + http_client: &project.http_client, + registry: ®istry, + path: &format!("/-/stage?{}", query.finish()), + authorization: authorization.as_deref(), + otp: None, + }).await?; + + let response: StageListResponse + = JsonDocument::hydrate_from_slice(&response)?; + + let page_size + = response.items.len(); + + items.extend(response.items); + + if items.len() >= response.total || page_size < per_page { + break; + } + + page += 1; + } + + if items.is_empty() { + if !self.json { + match &self.package { + Some(package) => println!("No staged versions found for package {}", package), + None => println!("No staged packages found"), + } + } + + return Ok(()); + } + + if !self.json { + println!("The following packages are awaiting approval. Use {} to approve them.\n", DataType::Code.colorize("yarn npm stage approve ")); + } + + let mut nodes = Vec::new(); + + for item in items { + let descriptor_string + = format!("{}@{}", item.package_name, item.tag); + let descriptor + = Descriptor::from_file_string(&descriptor_string) + .map_err(|_| Error::InvalidDescriptor(descriptor_string))?; + let locator + = Locator::from_file_string(&format!("{}@npm:{}", item.package_name, item.version))?; + + let children = IndexMap::from([ + ("ID".to_string(), tree::Node { + label: Some("ID".to_string()), + value: Some(AbstractValue::new(RawString::new(item.id))), + children: None, + }), + ("Staged".to_string(), tree::Node { + label: Some("Staged on".to_string()), + value: Some(AbstractValue::new(RawString::new(item.created_at))), + children: None, + }), + ]); + + nodes.push(tree::Node { + label: None, + value: Some(AbstractValue::new(DescriptorResolution::new(descriptor, locator))), + children: Some(tree::TreeNodeChildren::Map(children)), + }); + } + + let root = tree::Node { + label: None, + value: None, + children: Some(tree::TreeNodeChildren::Vec(nodes)), + }; + + print!("{}", tree::TreeRenderer::new().render(&root, self.json)); + + Ok(()) + } +} diff --git a/packages/zpm/src/commands/npm/stage/mod.rs b/packages/zpm/src/commands/npm/stage/mod.rs new file mode 100644 index 00000000..1b6bbaa1 --- /dev/null +++ b/packages/zpm/src/commands/npm/stage/mod.rs @@ -0,0 +1,53 @@ +use std::str::FromStr; + +use crate::{ + error::Error, + http_npm::{self, AuthorizationMode, GetAuthorizationOptions}, + project::Project, +}; + +pub mod approve; +pub mod list; +pub mod reject; + +#[derive(Debug)] +pub struct StageId(String); + +impl FromStr for StageId { + type Err = Error; + + fn from_str(value: &str) -> Result { + let valid = value.len() == 36 && value.bytes().enumerate().all(|(index, byte)| { + match index { + 8 | 13 | 18 | 23 => byte == b'-', + _ => byte.is_ascii_hexdigit(), + } + }); + + if !valid { + return Err(Error::InvalidNpmStageId(value.to_string())); + } + + Ok(Self(value.to_string())) + } +} + +async fn registry_auth(project: &Project) -> Result<(String, Option), Error> { + let workspace + = project.active_workspace()?; + + let registry + = http_npm::get_publish_registry(&project.config, &workspace.manifest)?.to_string(); + + let authorization + = http_npm::get_authorization(&GetAuthorizationOptions { + configuration: &project.config, + http_client: &project.http_client, + registry: ®istry, + ident: workspace.manifest.name.as_ref(), + auth_mode: AuthorizationMode::AlwaysAuthenticate, + allow_oidc: false, + }).await?; + + Ok((registry, authorization)) +} diff --git a/packages/zpm/src/commands/npm/stage/reject.rs b/packages/zpm/src/commands/npm/stage/reject.rs new file mode 100644 index 00000000..b38147d9 --- /dev/null +++ b/packages/zpm/src/commands/npm/stage/reject.rs @@ -0,0 +1,60 @@ +use clipanion::cli; +use zpm_utils::DataType; + +use crate::{ + error::Error, + http_npm::{self, NpmHttpParams}, + project::Project, + report::{with_report_result, StreamReport, StreamReportConfig}, +}; + +use super::{registry_auth, StageId}; + +/// Reject a staged package version. +/// +/// This command permanently removes a package version from the active workspace's publish registry's staging area. +/// If the registry requires two-factor authentication, use `--otp` or enter the code when prompted. +/// +#[cli::command] +#[cli::path("npm", "stage", "reject")] +#[cli::category("Npm-related commands")] +pub struct Reject { + /// The UUID of the staged package version + stage_id: StageId, + + /// One-time password to use when the registry requires two-factor authentication + #[cli::option("--otp")] + otp: Option, +} + +impl Reject { + pub async fn execute(&self) -> Result<(), Error> { + let project + = Project::new(None).await?; + + let report + = StreamReport::new(StreamReportConfig::from_config(&project.config)); + + with_report_result(report, async { + let (registry, authorization) + = registry_auth(&project).await?; + + let pretty_stage_id + = DataType::Code.colorize(&self.stage_id.0); + + println!("Rejecting staged package {}...", pretty_stage_id); + + http_npm::delete(&NpmHttpParams { + http_client: &project.http_client, + registry: ®istry, + path: &format!("/-/stage/{}", self.stage_id.0), + authorization: authorization.as_deref(), + otp: self.otp.as_deref(), + }).await?; + + println!("Staged package {} has been rejected.", pretty_stage_id); + + Ok(()) + }).await + } +} diff --git a/packages/zpm/src/error.rs b/packages/zpm/src/error.rs index ac836146..ac70356b 100644 --- a/packages/zpm/src/error.rs +++ b/packages/zpm/src/error.rs @@ -63,6 +63,9 @@ pub enum Error { #[error("Invalid publish access: {0}")] InvalidNpmPublishAccess(String), + #[error("Invalid npm stage ID ({0}); expected a UUID in the form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")] + InvalidNpmStageId(String), + #[error("Missing environment variable when creating the provenance payload: {0}")] MissingEnvironmentVariableForProvenancePayload(String), diff --git a/packages/zpm/src/http_npm.rs b/packages/zpm/src/http_npm.rs index 7f901a29..04a46d3f 100644 --- a/packages/zpm/src/http_npm.rs +++ b/packages/zpm/src/http_npm.rs @@ -17,6 +17,7 @@ use zpm_utils::{DataType, Path}; use crate::{ error::Error, http::{HttpClient, HttpRequest, HttpResponse}, + manifest::Manifest, npm, report::{current_report, PromptType}, }; @@ -134,6 +135,13 @@ pub fn get_registry_for_ident<'a>(config: &'a Configuration, ident: Option<&Iden Ok(normalize_registry_url(registry)) } +pub fn get_publish_registry<'a>(config: &'a Configuration, manifest: &'a Manifest) -> Result<&'a str, Error> { + match manifest.publish_config.registry.as_deref() { + Some(registry) => Ok(normalize_registry_url(registry)), + None => get_registry_for_ident(config, manifest.name.as_ref(), true), + } +} + pub fn get_registry<'a>(config: &'a Configuration, scope: Option<&str>, publish: bool) -> Result<&'a str, Error> { let scoped_ident = scope.map(|scope| Ident::new(format!("@{}/*", scope.strip_prefix('@').unwrap_or(scope)))); @@ -875,6 +883,37 @@ pub async fn post(params: &NpmHttpParams<'_>, body: String) -> Result) -> Result { + let url + = format!("{}{}", params.registry, params.path); + + let mut request + = params.http_client.request(url, reqwest::Method::DELETE)? + .enable_status_check(false) + .header("authorization", params.authorization); + + let mut response + = request + .try_clone() + .expect("Failed to clone request") + .send() + .await?; + + if is_otp_error(&response) { + let otp + = ask_for_otp(params, &response).await?; + + request = inject_otp_headers(request, otp); + drop(response); + response = request.send().await?; + } + + let response + = handle_invalid_authentication_error(params, response).await?; + + Ok(response.error_for_status()?) +} + pub async fn put(params: &NpmHttpParams<'_>, body: String) -> Result { let url = format!("{}{}", params.registry, params.path); diff --git a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts index 6505b574..0ce80f47 100644 --- a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts +++ b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts @@ -176,6 +176,10 @@ export enum RequestType { Repository = `repository`, Publish = `publish`, BulkAdvisories = `bulkAdvisories`, + StageList = `stageList`, + StageApprove = `stageApprove`, + StageReject = `stageReject`, + StagePublish = `stagePublish`, NodeDistIndex = `nodeDistIndex`, NodeDistTarball = `nodeDistTarball`, OtelTraces = `otelTraces`, @@ -225,6 +229,25 @@ export type Request = { } | { registry?: string; type: RequestType.BulkAdvisories; +} | { + registry?: string; + type: RequestType.StageList; + packageFilter?: string; + page: number; + perPage: number; +} | { + registry?: string; + type: RequestType.StageApprove; + stageId: string; +} | { + registry?: string; + type: RequestType.StageReject; + stageId: string; +} | { + registry?: string; + type: RequestType.StagePublish; + scope?: string; + localName: string; } | { type: RequestType.NodeDistIndex; } | { @@ -668,6 +691,20 @@ export const getPackageDirectoryPath = async ( return packageVersionEntry.path; }; +interface StagedPackageEntry { + id: string; + packageName: string; + version: string; + tag: string; + createdAt: string; + actor: string; + actorType: string; + access: string; + shasum: string; +} + +const stagedPackages = new Map(); + const packageServerUrls: { http: Promise | null; https: Promise | null; @@ -1007,6 +1044,104 @@ export const startPackageServer = ({type}: {type: keyof typeof packageServerUrls }); }, + async [RequestType.StageList](parsedRequest, _, response) { + if (parsedRequest.type !== RequestType.StageList) + throw new Error(`Assertion failed: Invalid request type`); + + const {packageFilter, page, perPage} = parsedRequest; + + let items = [...stagedPackages.values()]; + if (packageFilter) + items = items.filter(item => item.packageName === packageFilter); + + const total = items.length; + const paginatedItems = items.slice(page * perPage, (page + 1) * perPage); + + const data = JSON.stringify({items: paginatedItems, page, perPage, total}); + response.writeHead(200, {[`Content-Type`]: `application/json`}); + response.end(data); + }, + + async [RequestType.StageApprove](parsedRequest, _, response) { + if (parsedRequest.type !== RequestType.StageApprove) + throw new Error(`Assertion failed: Invalid request type`); + + const {stageId} = parsedRequest; + + if (!stagedPackages.has(stageId)) { + processError(response, 404, `Stage ID not found: ${stageId}`); + return; + } + + stagedPackages.delete(stageId); + + const data = JSON.stringify({message: `Package version approved and published successfully.`}); + response.writeHead(201, {[`Content-Type`]: `application/json`}); + response.end(data); + }, + + async [RequestType.StageReject](parsedRequest, _, response) { + if (parsedRequest.type !== RequestType.StageReject) + throw new Error(`Assertion failed: Invalid request type`); + + const {stageId} = parsedRequest; + + if (!stagedPackages.has(stageId)) { + processError(response, 404, `Stage ID not found: ${stageId}`); + return; + } + + stagedPackages.delete(stageId); + + response.writeHead(204); + response.end(); + }, + + async [RequestType.StagePublish](parsedRequest, request, response) { + if (parsedRequest.type !== RequestType.StagePublish) + throw new Error(`Assertion failed: Invalid request type`); + + const {scope, localName} = parsedRequest; + const name = scope ? `${scope}/${localName}` : localName; + + let rawData = ``; + + request.on(`data`, chunk => rawData += chunk); + request.on(`end`, () => { + let body; + try { + body = JSON.parse(rawData); + } catch { + return processError(response, 400, `Invalid JSON`); + } + + const [version] = Object.keys(body.versions); + if (!version) + return processError(response, 400, `Missing package version`); + + const tag = Object.keys(body[`dist-tags`])[0] || `latest`; + const shasum = body.versions[version]?.dist?.shasum || ``; + + const stageId = uuidv5(`${name}-${version}-${Date.now()}`, `06030d6c-8c43-412a-ad0a-787f1fb9e31e`); + + stagedPackages.set(stageId, { + id: stageId, + packageName: name, + version, + tag, + createdAt: new Date().toISOString(), + actor: `test-user`, + actorType: `user`, + access: body.access || `public`, + shasum, + }); + + const data = JSON.stringify({message: `Package version staged successfully.`, stageId}); + response.writeHead(201, {[`Content-Type`]: `application/json`}); + return response.end(data); + }); + }, + async [RequestType.BulkAdvisories](parsedRequest, request, response) { if (parsedRequest.type !== RequestType.BulkAdvisories) throw new Error(`Assertion failed: Invalid request type`); @@ -1249,6 +1384,35 @@ exit 0 ...registry, type: RequestType.BulkAdvisories, }; + } else if (url.match(/^\/-\/stage(\?|$)/) && method === `GET`) { + const urlObj = new URL(url, `http://localhost`); + return { + ...registry, + type: RequestType.StageList, + packageFilter: urlObj.searchParams.get(`package`) ?? undefined, + page: parseInt(urlObj.searchParams.get(`page`) ?? `0`, 10), + perPage: parseInt(urlObj.searchParams.get(`perPage`) ?? `100`, 10), + }; + } else if ((match = url.match(/^\/-\/stage\/([0-9a-f-]+)\/approve$/)) && method === `POST`) { + return { + ...registry, + type: RequestType.StageApprove, + stageId: match[1]!, + }; + } else if ((match = url.match(/^\/-\/stage\/([0-9a-f-]+)$/)) && method === `DELETE`) { + return { + ...registry, + type: RequestType.StageReject, + stageId: match[1]!, + }; + } else if ((match = url.match(/^\/-\/stage\/package\/(?:(@[^/]+)\/)?([^@/][^/]*)$/)) && method === `POST`) { + const [, scope, localName] = match; + return { + ...registry, + type: RequestType.StagePublish, + scope, + localName: localName!, + }; } else if ((match = url.match(/^\/(?:(@[^/]+)\/)?([^@/][^/]*)$/)) && method == `PUT`) { const [, scope, localName] = match; @@ -1298,6 +1462,10 @@ exit 0 const needsAuth = (parsedRequest: Request): boolean => { switch (parsedRequest.type) { + case RequestType.StageList: + case RequestType.StageApprove: + case RequestType.StageReject: + case RequestType.StagePublish: case RequestType.Publish: case RequestType.Whoami: return true; @@ -1345,7 +1513,10 @@ exit 0 return; } - if (!applyOtpValidation(req, res, user)) + const skipOtp = parsedRequest.type === RequestType.StageList + || parsedRequest.type === RequestType.StagePublish; + + if (!skipOtp && !applyOtpValidation(req, res, user)) return; if (parsedRequest.type === RequestType.Whoami) { diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/npm/stage.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/npm/stage.test.ts new file mode 100644 index 00000000..db5ce889 --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/npm/stage.test.ts @@ -0,0 +1,693 @@ +import {ppath, xfs} from '@yarnpkg/fslib'; + +const { + misc, + tests: {RequestType, startPackageServer, startRegistryRecording, validLogins}, +} = require(`pkg-tests-core`); + +function extractStageId(jsonStdout: string): string { + const jsonObjects = misc.parseJsonStream(jsonStdout); + const result = jsonObjects.find((obj: any) => obj?.stageId); + if (!result) + throw new Error(`Could not find stageId in JSON output:\n${jsonStdout}`); + + return result.stageId; +} + +describe(`Commands`, () => { + describe(`npm stage`, () => { + describe(`workspace registries`, () => { + for (const registrySource of [`manifest`, `scope`, `package`]) { + test( + `it should use the active workspace's ${registrySource} registry and credentials throughout the workflow`, + makeTemporaryEnv({ + private: true, + workspaces: [`packages/*`], + }, async ({path, run}) => { + const registry = await startPackageServer(); + const cwd = ppath.join(path, `packages/pkg`); + const manifest = { + name: `@scope/registry-${registrySource}`, + version: `1.0.0`, + ...(registrySource === `manifest` && { + publishConfig: {registry: `${registry}/registry/manifest/`}, + }), + }; + + await xfs.mkdirpPromise(cwd); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), manifest); + await xfs.writeJsonPromise(ppath.join(path, `.yarnrc.yml`), { + npmPublishRegistry: `${registry}/registry/default`, + npmScopes: { + scope: { + npmPublishRegistry: `${registry}/registry/scope`, + ...(registrySource === `scope` && {npmAuthToken: validLogins.fooUser.npmAuthToken}), + }, + }, + ...(registrySource !== `scope` && { + packageRules: [{ + packageFilter: `@scope/*`, + npmPublishRegistry: `${registry}/registry/package`, + ...(registrySource === `package` && {npmAuthToken: validLogins.fooUser.npmAuthToken}), + }], + }), + ...(registrySource === `manifest` && { + sourceRules: [{ + registryFilter: `${registry}/registry/manifest`, + npmAuthToken: validLogins.fooUser.npmAuthToken, + }], + }), + }); + + await run(`install`); + + // Each selected registry must override the unusable default credentials. + const options = {cwd, env: {YARN_NPM_AUTH_TOKEN: `invalid-token`}}; + const requests = await startRegistryRecording(async () => { + const {stdout: firstPublish} = await run(`npm`, `publish`, `--staged`, `--json`, options); + const firstId = extractStageId(firstPublish); + + manifest.version = `2.0.0`; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), manifest); + const {stdout: secondPublish} = await run(`npm`, `publish`, `--staged`, `--json`, options); + const secondId = extractStageId(secondPublish); + + const {stdout: listOut} = await run(`npm`, `stage`, `list`, manifest.name, `--json`, options); + expect(misc.parseJsonStream(listOut).map((item: any) => item.children.ID)).toEqual([firstId, secondId]); + + await run(`npm`, `stage`, `approve`, firstId, options); + await run(`npm`, `stage`, `reject`, secondId, options); + }); + + expect(requests).toEqual([ + expect.objectContaining({type: RequestType.StagePublish, registry: registrySource}), + expect.objectContaining({type: RequestType.StagePublish, registry: registrySource}), + expect.objectContaining({type: RequestType.StageList, registry: registrySource}), + expect.objectContaining({type: RequestType.StageApprove, registry: registrySource}), + expect.objectContaining({type: RequestType.StageReject, registry: registrySource}), + ]); + }), + ); + } + }); + + describe(`publish --staged`, () => { + test( + `it should stage a package for later approval`, + makeTemporaryEnv({ + name: `staged-pkg`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout} = await run(`npm`, `publish`, `--staged`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`Staging to`); + expect(stdout).toContain(`staged for approval`); + }), + ); + + test( + `it should not require OTP for staged publishing`, + makeTemporaryEnv({ + name: `staged-otp-pkg`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout} = await run(`npm`, `publish`, `--staged`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.otpUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`staged for approval`); + }), + ); + + test( + `it should support --dry-run with --staged`, + makeTemporaryEnv({ + name: `staged-dry-run`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const requests = await startRegistryRecording(async () => { + const {stdout} = await run(`npm`, `publish`, `--staged`, `--dry-run`, `--tolerate-republish`); + expect(stdout).toContain(`Staging to`); + expect(stdout).toContain(`dry run`); + }); + + expect(requests).not.toEqual(expect.arrayContaining([ + expect.objectContaining({type: RequestType.StagePublish}), + ])); + }), + ); + + test( + `it should support --json with --staged`, + makeTemporaryEnv({ + name: `staged-json`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout} = await run(`npm`, `publish`, `--staged`, `--json`, `--dry-run`, `--tolerate-republish`); + const jsonObjects = misc.parseJsonStream(stdout); + const result = jsonObjects.find((obj: any) => obj?.name && obj?.version); + + expect(result).toBeDefined(); + expect(result).toHaveProperty(`staged`, true); + expect(result).toHaveProperty(`published`, false); + expect(result).not.toHaveProperty(`stageId`); + }), + ); + + test( + `it should fail without authentication`, + makeTemporaryEnv({ + name: `staged-no-auth`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `publish`, `--staged`)).rejects.toThrow(); + }), + ); + }); + + describe(`list`, () => { + test( + `it should preserve scoped package names and tags in JSON and use the publish registry`, + makeTemporaryEnv({ + name: `@scope/staged-json`, + version: `1.2.3`, + }, async ({run}) => { + await run(`install`); + + const registry = `${await startPackageServer()}/registry/publish`; + const env = { + YARN_NPM_AUTH_TOKEN: validLogins.otpUser.npmAuthToken, + YARN_NPM_PUBLISH_REGISTRY: registry, + }; + + const requests = await startRegistryRecording(async () => { + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, `--tag`, `next`, {env}); + const stageId = extractStageId(publishOut); + const result = misc.parseJsonStream(publishOut).find((obj: any) => obj?.stageId); + + expect(result).toMatchObject({staged: true, published: false, dryRun: false, tag: `next`}); + + const {stdout} = await run(`npm`, `stage`, `list`, `@scope/staged-json`, `--json`, {env}); + expect(misc.parseJsonStream(stdout)).toEqual([{ + value: { + descriptor: `@scope/staged-json@next`, + locator: `@scope/staged-json@npm:1.2.3`, + }, + children: { + ID: stageId, + Staged: expect.any(String), + }, + }]); + + await run(`npm`, `stage`, `approve`, stageId, `--otp`, validLogins.otpUser.npmOtpToken, {env}); + }); + + expect(requests).toEqual(expect.arrayContaining([ + expect.objectContaining({type: RequestType.StagePublish, registry: `publish`, scope: `@scope`, localName: `staged-json`}), + expect.objectContaining({type: RequestType.StageList, registry: `publish`, packageFilter: `@scope/staged-json`}), + expect.objectContaining({type: RequestType.StageApprove, registry: `publish`}), + ])); + }), + ); + + test( + `it should fetch every page of staged packages`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + const registry = await startPackageServer(); + const token = validLogins.fooUser.npmAuthToken; + + await Promise.all(Array.from({length: 101}, async (_, index) => { + const version = `1.0.${index}`; + const response = await fetch(`${registry}/-/stage/package/paginated-stage`, { + method: `POST`, + headers: {authorization: `Bearer ${token}`, [`content-type`]: `application/json`}, + body: JSON.stringify({versions: {[version]: {}}, [`dist-tags`]: {latest: version}}), + }); + expect(response.status).toBe(201); + await response.text(); + })); + + const requests = await startRegistryRecording(async () => { + const {stdout} = await run(`npm`, `stage`, `list`, `paginated-stage`, `--json`, { + env: {YARN_NPM_AUTH_TOKEN: token}, + }); + const items = misc.parseJsonStream(stdout); + expect(items).toHaveLength(101); + expect(new Set(items.map((item: any) => item.children.ID)).size).toBe(101); + }); + + expect(requests).toEqual([ + {type: RequestType.StageList, packageFilter: `paginated-stage`, page: 0, perPage: 100}, + {type: RequestType.StageList, packageFilter: `paginated-stage`, page: 1, perPage: 100}, + ]); + }), + ); + + test( + `it should list an empty list when no packages are staged`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + const {stdout} = await run(`npm`, `stage`, `list`, `no-such-staged-pkg`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`No staged versions found`); + }), + ); + + test( + `it should list staged packages after staging one`, + makeTemporaryEnv({ + name: `list-after-stage`, + version: `2.0.0`, + }, async ({run}) => { + await run(`install`); + + await run(`npm`, `publish`, `--staged`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const {stdout} = await run(`npm`, `stage`, `list`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`list-after-stage`); + expect(stdout).toContain(`2.0.0`); + }), + ); + + test( + `it should filter by package name`, + makeTemporaryEnv({ + name: `filter-test-pkg`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + await run(`npm`, `publish`, `--staged`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const {stdout: matchOutput} = await run(`npm`, `stage`, `list`, `filter-test-pkg`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(matchOutput).toContain(`filter-test-pkg`); + + const {stdout: noMatchOutput} = await run(`npm`, `stage`, `list`, `nonexistent-pkg`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(noMatchOutput).toContain(`No staged versions found`); + }), + ); + + test( + `it should fail without authentication`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `stage`, `list`)).rejects.toThrow(); + }), + ); + }); + + describe(`approve`, () => { + for (const withOtp of [false, true]) { + test( + `it should approve multiple versions ${withOtp ? `with` : `without`} OTP`, + makeTemporaryEnv({ + name: `approve-multiple-${withOtp}`, + version: `1.0.0`, + }, async ({path, run}) => { + await run(`install`); + + const login = withOtp ? validLogins.otpUser : validLogins.fooUser; + const env = {YARN_NPM_AUTH_TOKEN: login.npmAuthToken}; + const manifestPath = ppath.join(path, `package.json`); + const manifest = await xfs.readJsonPromise(manifestPath); + const stageIds = []; + + for (const version of [`1.0.0`, `2.0.0`, `3.0.0`]) { + await xfs.writeJsonPromise(manifestPath, {...manifest, version}); + const {stdout} = await run(`npm`, `publish`, `--staged`, `--json`, {env}); + stageIds.push(extractStageId(stdout)); + } + + const otpArgs = withOtp ? [`--otp`, login.npmOtpToken] : []; + const {stdout} = await run(`npm`, `stage`, `approve`, ...stageIds, ...otpArgs, {env}); + for (const id of stageIds) + expect(stdout).toContain(`Staged package ${id} approved and published successfully.`); + + const {stdout: listOut} = await run(`npm`, `stage`, `list`, manifest.name, `--json`, {env}); + expect(listOut).toBe(``); + }), + ); + } + + test( + `it should validate every stage ID before approving any versions`, + makeTemporaryEnv({ + name: `approve-validate-all`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const env = {YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken}; + const {stdout} = await run(`npm`, `publish`, `--staged`, `--json`, {env}); + const stageId = extractStageId(stdout); + + const requests = await startRegistryRecording(async () => { + await expect(run(`npm`, `stage`, `approve`, stageId, `not-a-uuid`, {env})).rejects.toThrow(/Invalid npm stage ID/); + await expect(run(`npm`, `stage`, `approve`, {env})).rejects.toThrow(); + }); + + expect(requests).toEqual([]); + const {stdout: listOut} = await run(`npm`, `stage`, `list`, `approve-validate-all`, `--json`, {env}); + expect(misc.parseJsonStream(listOut)).toEqual([expect.objectContaining({ + children: expect.objectContaining({ID: stageId}), + })]); + }), + ); + + test( + `it should stop on a failed approval and retain earlier successful approvals`, + makeTemporaryEnv({ + name: `approve-partial-failure`, + version: `1.0.0`, + }, async ({path, run}) => { + await run(`install`); + + const env = {YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken}; + const {stdout: firstPublish} = await run(`npm`, `publish`, `--staged`, `--json`, {env}); + const firstId = extractStageId(firstPublish); + + const manifestPath = ppath.join(path, `package.json`); + const manifest = await xfs.readJsonPromise(manifestPath); + await xfs.writeJsonPromise(manifestPath, {...manifest, version: `2.0.0`}); + const {stdout: secondPublish} = await run(`npm`, `publish`, `--staged`, `--json`, {env}); + const secondId = extractStageId(secondPublish); + const missingId = `1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f`; + + const requests = await startRegistryRecording(async () => { + await expect(run(`npm`, `stage`, `approve`, firstId, missingId, secondId, {env})).rejects.toThrow(); + }); + + expect(requests).toEqual([ + {type: RequestType.StageApprove, stageId: firstId}, + {type: RequestType.StageApprove, stageId: missingId}, + ]); + + const {stdout: listOut} = await run(`npm`, `stage`, `list`, manifest.name, `--json`, {env}); + expect(misc.parseJsonStream(listOut)).toEqual([expect.objectContaining({ + children: expect.objectContaining({ID: secondId}), + })]); + }), + ); + + test( + `it should approve a staged package`, + makeTemporaryEnv({ + name: `approve-test`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const stageId = extractStageId(publishOut); + + const {stdout} = await run(`npm`, `stage`, `approve`, stageId, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`approved and published successfully`); + }), + ); + + test( + `it should approve with OTP`, + makeTemporaryEnv({ + name: `approve-otp-test`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const stageId = extractStageId(publishOut); + + const {stdout} = await run(`npm`, `stage`, `approve`, stageId, `--otp`, validLogins.otpUser.npmOtpToken, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.otpUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`approved and published successfully`); + }), + ); + + test( + `it should fail with invalid stage ID format`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `stage`, `approve`, `not-a-uuid`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + })).rejects.toThrow(/Invalid npm stage ID/); + }), + ); + + test( + `it should fail with non-existent stage ID`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `stage`, `approve`, `1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + })).rejects.toThrow(); + }), + ); + + test( + `it should fail without authentication`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `stage`, `approve`, `1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f`)).rejects.toThrow(); + }), + ); + }); + + describe(`reject`, () => { + test( + `it should reject with OTP and use the publish registry`, + makeTemporaryEnv({ + name: `reject-otp-test`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const env = { + YARN_NPM_AUTH_TOKEN: validLogins.otpUser.npmAuthToken, + YARN_NPM_PUBLISH_REGISTRY: `${await startPackageServer()}/registry/publish`, + }; + + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, {env}); + const stageId = extractStageId(publishOut); + + await expect(run(`npm`, `stage`, `reject`, stageId, `--otp`, `invalid_otp`, {env})).rejects.toThrow(/Invalid OTP token/); + + const requests = await startRegistryRecording(async () => { + const {stdout} = await run(`npm`, `stage`, `reject`, stageId, `--otp`, validLogins.otpUser.npmOtpToken, {env}); + expect(stdout).toContain(`has been rejected`); + }); + + expect(requests).toContainEqual({type: RequestType.StageReject, registry: `publish`, stageId}); + }), + ); + + test( + `it should reject a staged package`, + makeTemporaryEnv({ + name: `reject-test`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const stageId = extractStageId(publishOut); + + const {stdout} = await run(`npm`, `stage`, `reject`, stageId, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + expect(stdout).toContain(`has been rejected`); + }), + ); + + test( + `it should fail with invalid stage ID format`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `stage`, `reject`, `not-a-uuid`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + })).rejects.toThrow(/Invalid npm stage ID/); + }), + ); + + test( + `it should fail with non-existent stage ID`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + await expect(run(`npm`, `stage`, `reject`, `1de6f3db-2ed9-4d72-b3dd-8f0e2b474a2f`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + })).rejects.toThrow(); + }), + ); + }); + + describe(`full workflow`, () => { + test( + `it should support a stage -> list -> approve workflow`, + makeTemporaryEnv({ + name: `workflow-approve`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const stageId = extractStageId(publishOut); + + // List and verify it appears + const {stdout: listOut} = await run(`npm`, `stage`, `list`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + expect(listOut).toContain(`workflow-approve`); + expect(listOut).toContain(stageId); + + // Approve + await run(`npm`, `stage`, `approve`, stageId, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + // Verify it's gone from the list + const {stdout: listAfter} = await run(`npm`, `stage`, `list`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + expect(listAfter).not.toContain(stageId); + }), + ); + + test( + `it should support a stage -> list -> reject workflow`, + makeTemporaryEnv({ + name: `workflow-reject`, + version: `1.0.0`, + }, async ({run}) => { + await run(`install`); + + const {stdout: publishOut} = await run(`npm`, `publish`, `--staged`, `--json`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + const stageId = extractStageId(publishOut); + + // List and verify it appears + const {stdout: listOut} = await run(`npm`, `stage`, `list`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + expect(listOut).toContain(`workflow-reject`); + + // Reject + await run(`npm`, `stage`, `reject`, stageId, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + + // Verify it's gone from the list + const {stdout: listAfter} = await run(`npm`, `stage`, `list`, { + env: { + YARN_NPM_AUTH_TOKEN: validLogins.fooUser.npmAuthToken, + }, + }); + expect(listAfter).not.toContain(stageId); + }), + ); + }); + }); +});