From 8fec9929c4a107a25fd044269b45dcdbdde8f3c9 Mon Sep 17 00:00:00 2001 From: A Date: Thu, 25 Jun 2026 00:58:40 +0200 Subject: [PATCH] feat(cli): port set-script command to pacquet (#12618) * feat(cli): port set-script command * test(cli): cover set-script invoked with no arguments --- pacquet/crates/cli/src/cli_args.rs | 13 ++ pacquet/crates/cli/src/cli_args/set_script.rs | 97 +++++++++++ .../cli/src/cli_args/set_script/tests.rs | 32 ++++ pacquet/crates/cli/tests/set_script.rs | 158 ++++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 pacquet/crates/cli/src/cli_args/set_script.rs create mode 100644 pacquet/crates/cli/src/cli_args/set_script/tests.rs create mode 100644 pacquet/crates/cli/tests/set_script.rs diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index dc25da7ba8..c35a8b52b0 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -23,6 +23,7 @@ pub mod restart; pub mod run; pub mod runtime; pub mod sanitize; +pub mod set_script; pub mod stop; pub mod store; pub mod supported_architectures; @@ -63,6 +64,7 @@ use restart::RestartArgs; use run::RunArgs; use runtime::RuntimeArgs; use serde_json::Value; +use set_script::SetScriptArgs; use std::{ fs, future::Future, @@ -182,6 +184,9 @@ pub enum CliCommand { /// Remove existing patch files. #[clap(name = "patch-remove")] PatchRemove(PatchRemoveArgs), + /// Set a script in package.json + #[clap(visible_alias = "ss")] + SetScript(SetScriptArgs), /// Runs a package's "test" script, if one was provided. Test, /// Runs a defined package script. @@ -494,6 +499,14 @@ impl CliArgs { Ok(()) }), }, + // `set-script` only rewrites `package.json#scripts`; it never + // touches the lockfile or runs the install pipeline, so it + // dispatches synchronously off the canonicalized `--dir` like + // `init`, with no reporter-typed fan-out. + CliCommand::SetScript(args) => { + let result = args.run(manifest_path_ref); + Box::pin(std::future::ready(result)) + } CliCommand::Install(args) => Box::pin(async move { // Boxed for `clippy::large_stack_frames`: the three // monomorphized install futures would otherwise each reserve diff --git a/pacquet/crates/cli/src/cli_args/set_script.rs b/pacquet/crates/cli/src/cli_args/set_script.rs new file mode 100644 index 0000000000..d2ef1415a5 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/set_script.rs @@ -0,0 +1,97 @@ +use clap::Args; +use derive_more::{Display, Error}; +use miette::{Context, Diagnostic}; +use pacquet_package_manifest::PackageManifest; +use serde_json::{Map, Value}; +use std::path::Path; + +/// Keys that must never be written through a property path, because they can +/// corrupt an object's prototype chain. Mirrors `UNSAFE_KEYS` in +/// `@pnpm/object.property-path`. +const UNSAFE_KEYS: [&str; 3] = ["__proto__", "constructor", "prototype"]; + +/// Errors from `pacquet set-script`. +/// +/// Mirrors the error codes pnpm raises in `setScript.ts` and the unsafe-key +/// guard of `@pnpm/object.property-path` +/// (). +#[derive(Debug, Display, Error, Diagnostic)] +#[non_exhaustive] +pub enum SetScriptError { + #[display("Missing script name or command")] + #[diagnostic(code(ERR_PNPM_SET_SCRIPT_MISSING_ARGS))] + MissingArgs, + + #[display(r#"Key "{key}" is not allowed in a property path"#)] + #[diagnostic(code(ERR_PNPM_UNSAFE_PROPERTY_PATH_KEY))] + UnsafeKey { key: String }, +} + +/// `pacquet set-script ` / `pacquet ss `. +/// +/// Writes a `scripts` entry to the project manifest. The command is every +/// parameter after the name joined by a single space, so +/// `set-script lint eslint --fix src` stores `"eslint --fix src"`. +#[derive(Debug, Args)] +pub struct SetScriptArgs { + /// The script name followed by the command and its arguments. + #[clap(trailing_var_arg = true, allow_hyphen_values = true)] + pub params: Vec, +} + +impl SetScriptArgs { + /// Set `scripts[name] = command` on the manifest at `manifest_path`, + /// reading and rewriting `package.json` in place. + /// + /// Ports `setScript.ts`'s `handler`: a script name and a command are both + /// required, the script name is rejected when it is a prototype-pollution + /// key, and the name is used as a literal object key (a dot is not + /// interpreted as a nested path). + pub fn run(self, manifest_path: &Path) -> miette::Result<()> { + let mut params = self.params.into_iter(); + let Some(name) = params.next() else { + return Err(SetScriptError::MissingArgs.into()); + }; + let command_parts: Vec = params.collect(); + if command_parts.is_empty() { + return Err(SetScriptError::MissingArgs.into()); + } + reject_unsafe_key(&name)?; + let command = command_parts.join(" "); + + let mut manifest = PackageManifest::from_path(manifest_path.to_path_buf()) + .wrap_err("reading package.json")?; + set_script(manifest.value_mut(), name, command); + manifest.save().wrap_err("saving package.json")?; + Ok(()) + } +} + +/// Reject a script name that would pollute an object's prototype when used as +/// a key. Mirrors `rejectUnsafeKeys` in `@pnpm/object.property-path`. +fn reject_unsafe_key(key: &str) -> Result<(), SetScriptError> { + if UNSAFE_KEYS.contains(&key) { + return Err(SetScriptError::UnsafeKey { key: key.to_string() }); + } + Ok(()) +} + +/// Set `scripts[name] = command` on a manifest JSON value, creating the +/// `scripts` object when it is absent and replacing it when it is present but +/// not an object. Mirrors `setObjectValueByPropertyPath(manifest, ['scripts', +/// name], command)`, which rebuilds an intermediate node whose shape disagrees +/// with the path. +fn set_script(manifest: &mut Value, name: String, command: String) { + let Some(manifest) = manifest.as_object_mut() else { return }; + let scripts = manifest.entry("scripts").or_insert_with(|| Value::Object(Map::new())); + if !scripts.is_object() { + *scripts = Value::Object(Map::new()); + } + scripts + .as_object_mut() + .expect("scripts is an object after the reset above") + .insert(name, Value::String(command)); +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/cli/src/cli_args/set_script/tests.rs b/pacquet/crates/cli/src/cli_args/set_script/tests.rs new file mode 100644 index 0000000000..0fe226cabc --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/set_script/tests.rs @@ -0,0 +1,32 @@ +use super::{reject_unsafe_key, set_script}; +use serde_json::json; + +#[test] +fn creates_scripts_object_when_absent() { + let mut manifest = json!({ "name": "x" }); + set_script(&mut manifest, "build".to_string(), "tsc".to_string()); + assert_eq!(manifest["scripts"], json!({ "build": "tsc" })); +} + +#[test] +fn replaces_a_non_object_scripts_field() { + let mut manifest = json!({ "scripts": "oops" }); + set_script(&mut manifest, "build".to_string(), "tsc".to_string()); + assert_eq!(manifest["scripts"], json!({ "build": "tsc" })); +} + +#[test] +fn uses_a_dotted_name_as_a_literal_key() { + let mut manifest = json!({}); + set_script(&mut manifest, "pre.publish".to_string(), "echo".to_string()); + assert_eq!(manifest["scripts"], json!({ "pre.publish": "echo" })); +} + +#[test] +fn reject_unsafe_key_blocks_prototype_keys() { + for key in ["__proto__", "constructor", "prototype"] { + eprintln!("rejecting unsafe key: {key}"); + assert!(reject_unsafe_key(key).is_err()); + } + assert!(reject_unsafe_key("build").is_ok()); +} diff --git a/pacquet/crates/cli/tests/set_script.rs b/pacquet/crates/cli/tests/set_script.rs new file mode 100644 index 0000000000..1f18f88354 --- /dev/null +++ b/pacquet/crates/cli/tests/set_script.rs @@ -0,0 +1,158 @@ +use assert_cmd::prelude::*; +use command_extra::CommandExtra; +use pacquet_package_manifest::PackageManifest; +use pacquet_testing_utils::bin::CommandTempCwd; +use serde_json::{Value, json}; +use std::{fs, path::Path, process::Command}; + +fn pacquet_at(workspace: &Path) -> Command { + Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace) +} + +fn write_manifest(workspace: &Path, value: &Value) { + fs::write(workspace.join("package.json"), value.to_string()).expect("write package.json"); +} + +fn scripts(workspace: &Path) -> Value { + PackageManifest::from_path(workspace.join("package.json")) + .expect("read package.json") + .value() + .get("scripts") + .cloned() + .unwrap_or(Value::Null) +} + +#[test] +fn exposes_the_ss_alias() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + pacquet.with_args(["ss", "build", "tsc"]).assert().success(); + + assert_eq!(scripts(&workspace)["build"], json!("tsc")); + drop(root); +} + +#[test] +fn adds_a_script_when_none_exist() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + pacquet.with_args(["set-script", "build", "tsc -b"]).assert().success(); + + assert_eq!(scripts(&workspace), json!({ "build": "tsc -b" })); + drop(root); +} + +#[test] +fn overwrites_an_existing_script() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "scripts": { "build": "old" } })); + + pacquet.with_args(["set-script", "build", "tsc -b"]).assert().success(); + + assert_eq!(scripts(&workspace)["build"], json!("tsc -b")); + drop(root); +} + +#[test] +fn joins_remaining_params_into_the_command() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + pacquet.with_args(["set-script", "lint", "eslint", "--fix", "src"]).assert().success(); + + assert_eq!(scripts(&workspace)["lint"], json!("eslint --fix src")); + drop(root); +} + +#[test] +fn accepts_script_names_with_dots_hyphens_and_quotes() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + pacquet.with_args(["set-script", "my-build", "tsc -b"]).assert().success(); + pacquet_at(&workspace).with_args(["set-script", "pre.publish", "echo"]).assert().success(); + pacquet_at(&workspace) + .with_args(["set-script", r#"weird"name"#, "echo", "weird"]) + .assert() + .success(); + + let scripts = scripts(&workspace); + assert_eq!(scripts["my-build"], json!("tsc -b")); + assert_eq!(scripts["pre.publish"], json!("echo")); + assert_eq!(scripts[r#"weird"name"#], json!("echo weird")); + drop(root); +} + +#[test] +fn accepts_script_names_containing_an_equals_sign() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + pacquet.with_args(["set-script", "with=eq", "echo", "with=eq"]).assert().success(); + + assert_eq!(scripts(&workspace)["with=eq"], json!("echo with=eq")); + drop(root); +} + +#[test] +fn fails_when_arguments_are_missing() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + let output = + pacquet.with_args(["set-script", "build"]).output().expect("spawn pacquet set-script"); + assert!( + !output.status.success(), + "a missing command must fail (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("ERR_PNPM_SET_SCRIPT_MISSING_ARGS"), + "stderr must name the missing-args diagnostic; got:\n{stderr}", + ); + drop(root); +} + +#[test] +fn fails_when_no_arguments_are_given() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + // No arguments at all exercises the missing-name branch, separate from the + // missing-command branch covered above. + let output = pacquet.with_args(["set-script"]).output().expect("spawn pacquet set-script"); + assert!( + !output.status.success(), + "no arguments at all must fail (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("ERR_PNPM_SET_SCRIPT_MISSING_ARGS"), + "stderr must name the missing-args diagnostic; got:\n{stderr}", + ); + drop(root); +} + +#[test] +fn rejects_unsafe_script_names() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &json!({ "name": "test-package", "version": "1.0.0" })); + + let output = + pacquet.with_args(["set-script", "__proto__", "echo"]).output().expect("spawn pacquet"); + assert!( + !output.status.success(), + "an unsafe script name must fail (stderr: {})", + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("ERR_PNPM_UNSAFE_PROPERTY_PATH_KEY"), + "stderr must name the unsafe-key diagnostic; got:\n{stderr}", + ); + drop(root); +}