Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/zpm/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions packages/zpm/src/commands/npm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ pub mod login;
pub mod logout_all;
pub mod logout;
pub mod publish;
pub mod stage;
pub mod whoami;
71 changes: 57 additions & 14 deletions packages/zpm/src/commands/npm/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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: &registry_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<String>,
}

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: &registry_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))
Expand All @@ -333,7 +371,10 @@ impl Publish {
files: Vec<String>,
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,
}
Expand All @@ -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,
};
Expand Down
67 changes: 67 additions & 0 deletions packages/zpm/src/commands/npm/stage/approve.rs
Original file line number Diff line number Diff line change
@@ -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<StageId>,

/// One-time password to use when the registry requires two-factor authentication
#[cli::option("--otp")]
otp: Option<String>,
}

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: &registry,
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
}
}
152 changes: 152 additions & 0 deletions packages/zpm/src/commands/npm/stage/list.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[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<StagedPackage>,
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: &registry,
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 <stageId>"));
}

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(())
}
}
Loading
Loading