mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-20 12:42:38 -04:00
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:
@@ -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
|
||||
|
||||
97
pacquet/crates/cli/src/cli_args/set_script.rs
Normal file
97
pacquet/crates/cli/src/cli_args/set_script.rs
Normal 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;
|
||||
32
pacquet/crates/cli/src/cli_args/set_script/tests.rs
Normal file
32
pacquet/crates/cli/src/cli_args/set_script/tests.rs
Normal 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());
|
||||
}
|
||||
158
pacquet/crates/cli/tests/set_script.rs
Normal file
158
pacquet/crates/cli/tests/set_script.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user