diff --git a/src/compile/ir/tasks/mod.rs b/src/compile/ir/tasks/mod.rs index b91d9786..6e8c6189 100644 --- a/src/compile/ir/tasks/mod.rs +++ b/src/compile/ir/tasks/mod.rs @@ -35,6 +35,7 @@ pub mod extract_files; pub mod go_tool; pub mod java_tool_installer; pub mod npm; +pub mod npm_authenticate; pub mod nuget_authenticate; pub mod nuget_command; pub mod pip_authenticate; diff --git a/src/compile/ir/tasks/npm_authenticate.rs b/src/compile/ir/tasks/npm_authenticate.rs new file mode 100644 index 00000000..5118cfbc --- /dev/null +++ b/src/compile/ir/tasks/npm_authenticate.rs @@ -0,0 +1,208 @@ +//! Typed builder for `npmAuthenticate@0`. + +use crate::compile::ir::step::TaskStep; + +/// Builder for a [`TaskStep`] invoking `npmAuthenticate@0`. +/// +/// Searches the specified `.npmrc` file for registry entries, then appends +/// authentication details for the discovered registries to the end of the +/// file. For registries in the current organization/collection, the build's +/// credentials are used automatically. For registries in a different +/// organization or hosted by a third party, the registry URIs are compared +/// against the URIs of npm service connections supplied via +/// [`custom_endpoint`](Self::custom_endpoint). +/// +/// The `.npmrc` file is reverted to its original state at the end of pipeline +/// execution. +/// +/// ADO task reference: +/// +#[derive(Debug, Clone)] +pub struct NpmAuthenticate { + working_file: String, + custom_endpoint: Option, + azure_devops_service_connection: Option, + feed_url: Option, + display_name: Option, +} + +impl NpmAuthenticate { + /// Create a new builder. + /// + /// `working_file` — path to the `.npmrc` file to authenticate + /// (e.g. `".npmrc"` or `"$(Agent.TempDirectory)/.npmrc"`). The file must + /// exist before this step runs; use a preceding bash step to create it if + /// the repository does not already contain one. + pub fn new(working_file: impl Into) -> Self { + Self { + working_file: working_file.into(), + custom_endpoint: None, + azure_devops_service_connection: None, + feed_url: None, + display_name: None, + } + } + + /// `customEndpoint` — comma-separated names of npm service connections + /// configured for registries outside this organization/collection. + /// Registry URIs in the `.npmrc` file are matched against the service + /// connection URIs, and the corresponding credentials are used. + pub fn custom_endpoint(mut self, value: impl Into) -> Self { + self.custom_endpoint = Some(value.into()); + self + } + + /// `azureDevOpsServiceConnection` (alias: `workloadIdentityServiceConnection`) — + /// workload-identity service connection for authenticating against this + /// Azure DevOps organization's Azure Artifacts feeds. When set, + /// [`feed_url`](Self::feed_url) is required. Not compatible with + /// [`custom_endpoint`](Self::custom_endpoint). + pub fn azure_devops_service_connection(mut self, value: impl Into) -> Self { + self.azure_devops_service_connection = Some(value.into()); + self + } + + /// `feedUrl` — Azure Artifacts feed URL in npm registry format: + /// `https://pkgs.dev.azure.com/{ORG}/{PROJECT}/_packaging/{FEED}/npm/registry/`. + /// Required when + /// [`azure_devops_service_connection`](Self::azure_devops_service_connection) + /// is set. Not compatible with [`custom_endpoint`](Self::custom_endpoint). + pub fn feed_url(mut self, value: impl Into) -> Self { + self.feed_url = Some(value.into()); + self + } + + /// Override the default `displayName` (`"npm Authenticate"`). + pub fn with_display_name(mut self, value: impl Into) -> Self { + self.display_name = Some(value.into()); + self + } + + /// Lower into a [`TaskStep`]. + pub fn into_step(self) -> TaskStep { + let mut t = TaskStep::new( + "npmAuthenticate@0", + self.display_name + .unwrap_or_else(|| "npm Authenticate".into()), + ) + .with_input("workingFile", self.working_file); + if let Some(v) = self.custom_endpoint { + t = t.with_input("customEndpoint", v); + } + if let Some(v) = self.azure_devops_service_connection { + t = t.with_input("azureDevOpsServiceConnection", v); + } + if let Some(v) = self.feed_url { + t = t.with_input("feedUrl", v); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_task_identifier() { + let t = NpmAuthenticate::new(".npmrc").into_step(); + assert_eq!(t.task, "npmAuthenticate@0"); + } + + #[test] + fn working_file_is_always_emitted() { + let t = NpmAuthenticate::new(".npmrc").into_step(); + assert_eq!( + t.inputs.get("workingFile").map(String::as_str), + Some(".npmrc") + ); + } + + #[test] + fn default_display_name() { + let t = NpmAuthenticate::new(".npmrc").into_step(); + assert_eq!(t.display_name, "npm Authenticate"); + } + + #[test] + fn no_optional_inputs_by_default() { + let t = NpmAuthenticate::new(".npmrc").into_step(); + assert_eq!(t.inputs.len(), 1, "only workingFile should be set"); + } + + #[test] + fn custom_endpoint() { + let t = NpmAuthenticate::new(".npmrc") + .custom_endpoint("OtherOrgNpmConn,ThirdPartyConn") + .into_step(); + assert_eq!( + t.inputs.get("customEndpoint").map(String::as_str), + Some("OtherOrgNpmConn,ThirdPartyConn") + ); + } + + #[test] + fn azure_devops_service_connection_mode() { + let t = NpmAuthenticate::new(".npmrc") + .azure_devops_service_connection("my-wif-conn") + .feed_url("https://pkgs.dev.azure.com/myorg/_packaging/myfeed/npm/registry/") + .into_step(); + assert_eq!( + t.inputs + .get("azureDevOpsServiceConnection") + .map(String::as_str), + Some("my-wif-conn") + ); + assert_eq!( + t.inputs.get("feedUrl").map(String::as_str), + Some("https://pkgs.dev.azure.com/myorg/_packaging/myfeed/npm/registry/") + ); + } + + #[test] + fn with_display_name_override() { + let t = NpmAuthenticate::new(".npmrc") + .with_display_name("Authenticate npm (build service identity)") + .into_step(); + assert_eq!(t.display_name, "Authenticate npm (build service identity)"); + } + + #[test] + fn temp_directory_working_file() { + let t = NpmAuthenticate::new("$(Agent.TempDirectory)/.npmrc").into_step(); + assert_eq!( + t.inputs.get("workingFile").map(String::as_str), + Some("$(Agent.TempDirectory)/.npmrc") + ); + } + + #[test] + fn all_optional_inputs_together() { + let t = NpmAuthenticate::new(".npmrc") + .custom_endpoint("external-npm-conn") + .azure_devops_service_connection("wif-conn") + .feed_url("https://pkgs.dev.azure.com/myorg/_packaging/myfeed/npm/registry/") + .with_display_name("Auth npm feeds") + .into_step(); + assert_eq!(t.task, "npmAuthenticate@0"); + assert_eq!(t.display_name, "Auth npm feeds"); + assert_eq!( + t.inputs.get("workingFile").map(String::as_str), + Some(".npmrc") + ); + assert_eq!( + t.inputs.get("customEndpoint").map(String::as_str), + Some("external-npm-conn") + ); + assert_eq!( + t.inputs + .get("azureDevOpsServiceConnection") + .map(String::as_str), + Some("wif-conn") + ); + assert_eq!( + t.inputs.get("feedUrl").map(String::as_str), + Some("https://pkgs.dev.azure.com/myorg/_packaging/myfeed/npm/registry/") + ); + } +} diff --git a/src/runtimes/node/extension.rs b/src/runtimes/node/extension.rs index 604a2a49..a352c577 100644 --- a/src/runtimes/node/extension.rs +++ b/src/runtimes/node/extension.rs @@ -3,6 +3,7 @@ use super::{NODE_BASH_COMMANDS, NodeRuntimeConfig}; use crate::compile::extensions::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; use crate::compile::ir::step::{BashStep, Step, TaskStep}; +use crate::compile::ir::tasks::npm_authenticate::NpmAuthenticate; use crate::compile::ir::tasks::use_node::UseNode; use crate::validate; use anyhow::Result; @@ -131,11 +132,9 @@ fn node_install_task_step(config: &NodeRuntimeConfig) -> TaskStep { /// Build the typed [`TaskStep`] for npm authentication. fn npm_authenticate_task_step() -> TaskStep { - TaskStep::new( - "npmAuthenticate@0", - "Authenticate npm (build service identity)", - ) - .with_input("workingFile", ".npmrc") + NpmAuthenticate::new(".npmrc") + .with_display_name("Authenticate npm (build service identity)") + .into_step() } /// Build the typed [`BashStep`] that ensures `.npmrc`. The script