feat(cli): port set-script command to pacquet (#12618)

* feat(cli): port set-script command

* test(cli): cover set-script invoked with no arguments
This commit is contained in:
A
2026-06-25 00:58:40 +02:00
committed by GitHub
parent b1839d4ce0
commit 8fec9929c4
4 changed files with 300 additions and 0 deletions

View File

@@ -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

View File

@@ -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`
/// (<https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/pkg-manifest/commands/src/setScript.ts>).
#[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 <name> <command...>` / `pacquet ss <name> <command...>`.
///
/// 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<String>,
}
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<String> = 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;

View File

@@ -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());
}

View File

@@ -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);
}