Skip to content

Commit 128c60d

Browse files
anonriglemire
andauthored
cli: implement node --run <script-in-package-json>
Co-authored-by: Daniel Lemire <daniel@lemire.me> PR-URL: #52190 Reviewed-By: Daniel Lemire <daniel@lemire.me> Reviewed-By: Vinícius Lourenço Claro Cardoso <contact@viniciusl.com.br> Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com> Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Robert Nagy <ronagy@icloud.com> Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Tierney Cyren <hello@bnb.im> Reviewed-By: Chemi Atlow <chemi@atlow.co.il> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Ruy Adorno <ruy@vlt.sh>
1 parent ad86a12 commit 128c60d

20 files changed

+352
-0
lines changed

doc/api/cli.md

+44
Original file line numberDiff line numberDiff line change
@@ -1807,6 +1807,50 @@ Only CommonJS modules are supported.
18071807
Use [`--import`][] to preload an [ECMAScript module][].
18081808
Modules preloaded with `--require` will run before modules preloaded with `--import`.
18091809

1810+
### `--run`
1811+
1812+
<!-- YAML
1813+
added: REPLACEME
1814+
-->
1815+
1816+
> Stability: 1.1 - Active development
1817+
1818+
This runs a specified command from a package.json's `"scripts"` object.
1819+
If no `"command"` is provided, it will list the available scripts.
1820+
1821+
`--run` prepends `./node_modules/.bin`, relative to the current
1822+
working directory, to the `PATH` in order to execute the binaries from
1823+
dependencies.
1824+
1825+
For example, the following command will run the `test` script of
1826+
the `package.json` in the current folder:
1827+
1828+
```console
1829+
$ node --run test
1830+
```
1831+
1832+
You can also pass arguments to the command. Any argument after `--` will
1833+
be appended to the script:
1834+
1835+
```console
1836+
$ node --run test -- --verbose
1837+
```
1838+
1839+
#### Intentional limitations
1840+
1841+
`node --run` is not meant to match the behaviors of `npm run` or of the `run`
1842+
commands of other package managers. The Node.js implementation is intentionally
1843+
more limited, in order to focus on top performance for the most common use
1844+
cases.
1845+
Some features of other `run` implementations that are intentionally excluded
1846+
are:
1847+
1848+
* Searching for `package.json` files outside the current folder.
1849+
* Prepending the `.bin` or `node_modules/.bin` paths of folders outside the
1850+
current folder.
1851+
* Running `pre` or `post` scripts in addition to the specified script.
1852+
* Defining package manager-specific environment variables.
1853+
18101854
### `--secure-heap=n`
18111855

18121856
<!-- YAML

lib/internal/main/run.js

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use strict';
2+
/* eslint-disable node-core/prefer-primordials */
3+
4+
// There is no need to add primordials to this file.
5+
// `run.js` is a script only executed when `node --run <script>` is called.
6+
const {
7+
prepareMainThreadExecution,
8+
markBootstrapComplete,
9+
} = require('internal/process/pre_execution');
10+
const { getPackageJSONScripts } = internalBinding('modules');
11+
const { execSync } = require('child_process');
12+
const { resolve, delimiter } = require('path');
13+
const { escapeShell } = require('internal/shell');
14+
const { getOptionValue } = require('internal/options');
15+
const { emitExperimentalWarning } = require('internal/util');
16+
17+
prepareMainThreadExecution(false, false);
18+
markBootstrapComplete();
19+
emitExperimentalWarning('Task runner');
20+
21+
// TODO(@anonrig): Search for all package.json's until root folder.
22+
const json_string = getPackageJSONScripts();
23+
24+
// Check if package.json exists and is parseable
25+
if (json_string === undefined) {
26+
process.exitCode = 1;
27+
return;
28+
}
29+
const scripts = JSON.parse(json_string);
30+
// Remove the first argument, which are the node binary.
31+
const args = process.argv.slice(1);
32+
const id = getOptionValue('--run');
33+
let command = scripts[id];
34+
35+
if (!command) {
36+
const { error } = require('internal/console/global');
37+
38+
error(`Missing script: "${id}"\n`);
39+
40+
const keys = Object.keys(scripts);
41+
if (keys.length === 0) {
42+
error('There are no scripts available in package.json');
43+
} else {
44+
error('Available scripts are:');
45+
for (const script of keys) {
46+
error(` ${script}: ${scripts[script]}`);
47+
}
48+
}
49+
process.exit(1);
50+
return;
51+
}
52+
53+
const env = process.env;
54+
const cwd = process.cwd();
55+
const binPath = resolve(cwd, 'node_modules/.bin');
56+
57+
// Filter all environment variables that contain the word "path"
58+
const keys = Object.keys(env).filter((key) => /^path$/i.test(key));
59+
const PATH = keys.map((key) => env[key]);
60+
61+
// Append only the current folder bin path to the PATH variable.
62+
// TODO(@anonrig): Prepend the bin path of all parent folders.
63+
const paths = [binPath, PATH].join(delimiter);
64+
for (const key of keys) {
65+
env[key] = paths;
66+
}
67+
68+
// If there are any remaining arguments left, append them to the command.
69+
// This is useful if you want to pass arguments to the script, such as
70+
// `node --run linter -- --help` which runs `biome --check . --help`
71+
if (args.length > 0) {
72+
command += ' ' + escapeShell(args.map((arg) => arg.trim()).join(' '));
73+
}
74+
execSync(command, { stdio: 'inherit', env, shell: true });

lib/internal/shell.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use strict';
2+
3+
4+
// There is no need to add primordials to this file.
5+
// `shell.js` is a script only executed when `node run <script>` is called.
6+
7+
const forbiddenCharacters = /[\t\n\r "#$&'()*;<>?\\`|~]/;
8+
9+
/**
10+
* Escapes a string to be used as a shell argument.
11+
*
12+
* Adapted from `promise-spawn` module available under ISC license.
13+
* Ref: https://github.com/npm/promise-spawn/blob/16b36410f9b721dbe190141136432a418869734f/lib/escape.js
14+
* @param {string} input
15+
*/
16+
function escapeShell(input) {
17+
// If the input is an empty string, return a pair of quotes
18+
if (!input.length) {
19+
return '\'\'';
20+
}
21+
22+
// Check if input contains any forbidden characters
23+
// If it doesn't, return the input as is.
24+
if (!forbiddenCharacters.test(input)) {
25+
return input;
26+
}
27+
28+
// Replace single quotes with '\'' and wrap the whole result in a fresh set of quotes
29+
return `'${input.replace(/'/g, '\'\\\'\'')}'`
30+
// If the input string already had single quotes around it, clean those up
31+
.replace(/^(?:'')+(?!$)/, '')
32+
.replace(/\\'''/g, '\\\'');
33+
}
34+
35+
module.exports = {
36+
escapeShell,
37+
};

src/node.cc

+4
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,10 @@ MaybeLocal<Value> StartExecution(Environment* env, StartExecutionCallback cb) {
409409
return StartExecution(env, "internal/main/watch_mode");
410410
}
411411

412+
if (!env->options()->run.empty()) {
413+
return StartExecution(env, "internal/main/run");
414+
}
415+
412416
if (!first_argv.empty() && first_argv != "-") {
413417
return StartExecution(env, "internal/main/run_main_module");
414418
}

src/node_modules.cc

+40
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "base_object-inl.h"
44
#include "node_errors.h"
55
#include "node_external_reference.h"
6+
#include "node_process-inl.h"
67
#include "node_url.h"
78
#include "permission/permission.h"
89
#include "permission/permission_base.h"
@@ -219,6 +220,21 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON(
219220
if (field_value == "commonjs" || field_value == "module") {
220221
package_config.type = field_value;
221222
}
223+
} else if (key == "scripts") {
224+
if (value.type().get(field_type)) {
225+
return throw_invalid_package_config();
226+
}
227+
switch (field_type) {
228+
case simdjson::ondemand::json_type::object: {
229+
if (value.raw_json().get(field_value)) {
230+
return throw_invalid_package_config();
231+
}
232+
package_config.scripts = field_value;
233+
break;
234+
}
235+
default:
236+
break;
237+
}
222238
}
223239
}
224240
// package_config could be quite large, so we should move it instead of
@@ -344,6 +360,28 @@ void BindingData::GetNearestParentPackageJSONType(
344360
args.GetReturnValue().Set(Array::New(realm->isolate(), values, 3));
345361
}
346362

363+
void BindingData::GetPackageJSONScripts(
364+
const FunctionCallbackInfo<Value>& args) {
365+
Realm* realm = Realm::GetCurrent(args);
366+
std::string_view path = "package.json";
367+
368+
THROW_IF_INSUFFICIENT_PERMISSIONS(
369+
realm->env(), permission::PermissionScope::kFileSystemRead, path);
370+
371+
auto package_json = GetPackageJSON(realm, path);
372+
if (package_json == nullptr) {
373+
printf("Can't read package.json\n");
374+
return;
375+
} else if (!package_json->scripts.has_value()) {
376+
printf("Can't read package.json \"scripts\" object\n");
377+
return;
378+
}
379+
380+
args.GetReturnValue().Set(
381+
ToV8Value(realm->context(), package_json->scripts.value())
382+
.ToLocalChecked());
383+
}
384+
347385
void BindingData::GetPackageScopeConfig(
348386
const FunctionCallbackInfo<Value>& args) {
349387
CHECK_GE(args.Length(), 1);
@@ -424,6 +462,7 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
424462
"getNearestParentPackageJSON",
425463
GetNearestParentPackageJSON);
426464
SetMethod(isolate, target, "getPackageScopeConfig", GetPackageScopeConfig);
465+
SetMethod(isolate, target, "getPackageJSONScripts", GetPackageJSONScripts);
427466
}
428467

429468
void BindingData::CreatePerContextProperties(Local<Object> target,
@@ -440,6 +479,7 @@ void BindingData::RegisterExternalReferences(
440479
registry->Register(GetNearestParentPackageJSONType);
441480
registry->Register(GetNearestParentPackageJSON);
442481
registry->Register(GetPackageScopeConfig);
482+
registry->Register(GetPackageJSONScripts);
443483
}
444484

445485
} // namespace modules

src/node_modules.h

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class BindingData : public SnapshotableObject {
3232
std::string type = "none";
3333
std::optional<std::string> exports;
3434
std::optional<std::string> imports;
35+
std::optional<std::string> scripts;
3536
std::string raw_json;
3637

3738
v8::Local<v8::Array> Serialize(Realm* realm) const;
@@ -60,6 +61,8 @@ class BindingData : public SnapshotableObject {
6061
const v8::FunctionCallbackInfo<v8::Value>& args);
6162
static void GetPackageScopeConfig(
6263
const v8::FunctionCallbackInfo<v8::Value>& args);
64+
static void GetPackageJSONScripts(
65+
const v8::FunctionCallbackInfo<v8::Value>& args);
6366

6467
static void CreatePerIsolateProperties(IsolateData* isolate_data,
6568
v8::Local<v8::ObjectTemplate> ctor);

src/node_options.cc

+3
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,9 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
573573
&EnvironmentOptions::prof_process);
574574
// Options after --prof-process are passed through to the prof processor.
575575
AddAlias("--prof-process", { "--prof-process", "--" });
576+
AddOption("--run",
577+
"Run a script specified in package.json",
578+
&EnvironmentOptions::run);
576579
#if HAVE_INSPECTOR
577580
AddOption("--cpu-prof",
578581
"Start the V8 CPU profiler on start up, and write the CPU profile "

src/node_options.h

+1
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ class EnvironmentOptions : public Options {
161161
bool heap_prof = false;
162162
#endif // HAVE_INSPECTOR
163163
std::string redirect_warnings;
164+
std::string run;
164165
std::string diagnostic_dir;
165166
std::string env_file;
166167
bool has_env_file_string = false;

test/fixtures/run-script/.env

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CUSTOM_ENV="hello world"

test/fixtures/run-script/node_modules/.bin/ada

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/run-script/node_modules/.bin/ada.bat

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/run-script/node_modules/.bin/custom-env

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/run-script/node_modules/.bin/custom-env.bat

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/run-script/node_modules/.bin/positional-args

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/run-script/node_modules/.bin/positional-args.bat

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/fixtures/run-script/package.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"scripts": {
3+
"test": "echo \"Error: no test specified\" && exit 1",
4+
"ada": "ada",
5+
"ada-windows": "ada.bat",
6+
"positional-args": "positional-args",
7+
"positional-args-windows": "positional-args.bat",
8+
"custom-env": "custom-env",
9+
"custom-env-windows": "custom-env.bat"
10+
}
11+
}

test/message/node_run_non_existent.js

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use strict';
2+
3+
require('../common');
4+
const assert = require('node:assert').strict;
5+
const childProcess = require('node:child_process');
6+
const fixtures = require('../common/fixtures');
7+
8+
const child = childProcess.spawnSync(
9+
process.execPath,
10+
[ '--run', 'non-existent-command'],
11+
{ cwd: fixtures.path('run-script'), encoding: 'utf8' },
12+
);
13+
assert.strictEqual(child.status, 1);
14+
console.log(child.stderr);
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Missing script: "non-existent-command"
2+
3+
Available scripts are:
4+
test: echo "Error: no test specified" && exit 1
5+
ada: ada
6+
ada-windows: ada.bat
7+
positional-args: positional-args
8+
positional-args-windows: positional-args.bat
9+
custom-env: custom-env
10+
custom-env-windows: custom-env.bat

0 commit comments

Comments
 (0)