cli: implement node --run <script-in-package-json>

Co-authored-by: Daniel Lemire <daniel@lemire.me>
PR-URL: https://github.com/nodejs/node/pull/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>
This commit is contained in:
Yagiz Nizipli 2024-04-07 20:49:14 -04:00 committed by GitHub
parent ad86a12964
commit 128c60d906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 352 additions and 0 deletions

View file

@ -1807,6 +1807,50 @@ Only CommonJS modules are supported.
Use [`--import`][] to preload an [ECMAScript module][]. Use [`--import`][] to preload an [ECMAScript module][].
Modules preloaded with `--require` will run before modules preloaded with `--import`. Modules preloaded with `--require` will run before modules preloaded with `--import`.
### `--run`
<!-- YAML
added: REPLACEME
-->
> Stability: 1.1 - Active development
This runs a specified command from a package.json's `"scripts"` object.
If no `"command"` is provided, it will list the available scripts.
`--run` prepends `./node_modules/.bin`, relative to the current
working directory, to the `PATH` in order to execute the binaries from
dependencies.
For example, the following command will run the `test` script of
the `package.json` in the current folder:
```console
$ node --run test
```
You can also pass arguments to the command. Any argument after `--` will
be appended to the script:
```console
$ node --run test -- --verbose
```
#### Intentional limitations
`node --run` is not meant to match the behaviors of `npm run` or of the `run`
commands of other package managers. The Node.js implementation is intentionally
more limited, in order to focus on top performance for the most common use
cases.
Some features of other `run` implementations that are intentionally excluded
are:
* Searching for `package.json` files outside the current folder.
* Prepending the `.bin` or `node_modules/.bin` paths of folders outside the
current folder.
* Running `pre` or `post` scripts in addition to the specified script.
* Defining package manager-specific environment variables.
### `--secure-heap=n` ### `--secure-heap=n`
<!-- YAML <!-- YAML

74
lib/internal/main/run.js Normal file
View file

@ -0,0 +1,74 @@
'use strict';
/* eslint-disable node-core/prefer-primordials */
// There is no need to add primordials to this file.
// `run.js` is a script only executed when `node --run <script>` is called.
const {
prepareMainThreadExecution,
markBootstrapComplete,
} = require('internal/process/pre_execution');
const { getPackageJSONScripts } = internalBinding('modules');
const { execSync } = require('child_process');
const { resolve, delimiter } = require('path');
const { escapeShell } = require('internal/shell');
const { getOptionValue } = require('internal/options');
const { emitExperimentalWarning } = require('internal/util');
prepareMainThreadExecution(false, false);
markBootstrapComplete();
emitExperimentalWarning('Task runner');
// TODO(@anonrig): Search for all package.json's until root folder.
const json_string = getPackageJSONScripts();
// Check if package.json exists and is parseable
if (json_string === undefined) {
process.exitCode = 1;
return;
}
const scripts = JSON.parse(json_string);
// Remove the first argument, which are the node binary.
const args = process.argv.slice(1);
const id = getOptionValue('--run');
let command = scripts[id];
if (!command) {
const { error } = require('internal/console/global');
error(`Missing script: "${id}"\n`);
const keys = Object.keys(scripts);
if (keys.length === 0) {
error('There are no scripts available in package.json');
} else {
error('Available scripts are:');
for (const script of keys) {
error(` ${script}: ${scripts[script]}`);
}
}
process.exit(1);
return;
}
const env = process.env;
const cwd = process.cwd();
const binPath = resolve(cwd, 'node_modules/.bin');
// Filter all environment variables that contain the word "path"
const keys = Object.keys(env).filter((key) => /^path$/i.test(key));
const PATH = keys.map((key) => env[key]);
// Append only the current folder bin path to the PATH variable.
// TODO(@anonrig): Prepend the bin path of all parent folders.
const paths = [binPath, PATH].join(delimiter);
for (const key of keys) {
env[key] = paths;
}
// If there are any remaining arguments left, append them to the command.
// This is useful if you want to pass arguments to the script, such as
// `node --run linter -- --help` which runs `biome --check . --help`
if (args.length > 0) {
command += ' ' + escapeShell(args.map((arg) => arg.trim()).join(' '));
}
execSync(command, { stdio: 'inherit', env, shell: true });

37
lib/internal/shell.js Normal file
View file

@ -0,0 +1,37 @@
'use strict';
// There is no need to add primordials to this file.
// `shell.js` is a script only executed when `node run <script>` is called.
const forbiddenCharacters = /[\t\n\r "#$&'()*;<>?\\`|~]/;
/**
* Escapes a string to be used as a shell argument.
*
* Adapted from `promise-spawn` module available under ISC license.
* Ref: https://github.com/npm/promise-spawn/blob/16b36410f9b721dbe190141136432a418869734f/lib/escape.js
* @param {string} input
*/
function escapeShell(input) {
// If the input is an empty string, return a pair of quotes
if (!input.length) {
return '\'\'';
}
// Check if input contains any forbidden characters
// If it doesn't, return the input as is.
if (!forbiddenCharacters.test(input)) {
return input;
}
// Replace single quotes with '\'' and wrap the whole result in a fresh set of quotes
return `'${input.replace(/'/g, '\'\\\'\'')}'`
// If the input string already had single quotes around it, clean those up
.replace(/^(?:'')+(?!$)/, '')
.replace(/\\'''/g, '\\\'');
}
module.exports = {
escapeShell,
};

View file

@ -409,6 +409,10 @@ MaybeLocal<Value> StartExecution(Environment* env, StartExecutionCallback cb) {
return StartExecution(env, "internal/main/watch_mode"); return StartExecution(env, "internal/main/watch_mode");
} }
if (!env->options()->run.empty()) {
return StartExecution(env, "internal/main/run");
}
if (!first_argv.empty() && first_argv != "-") { if (!first_argv.empty() && first_argv != "-") {
return StartExecution(env, "internal/main/run_main_module"); return StartExecution(env, "internal/main/run_main_module");
} }

View file

@ -3,6 +3,7 @@
#include "base_object-inl.h" #include "base_object-inl.h"
#include "node_errors.h" #include "node_errors.h"
#include "node_external_reference.h" #include "node_external_reference.h"
#include "node_process-inl.h"
#include "node_url.h" #include "node_url.h"
#include "permission/permission.h" #include "permission/permission.h"
#include "permission/permission_base.h" #include "permission/permission_base.h"
@ -219,6 +220,21 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON(
if (field_value == "commonjs" || field_value == "module") { if (field_value == "commonjs" || field_value == "module") {
package_config.type = field_value; package_config.type = field_value;
} }
} else if (key == "scripts") {
if (value.type().get(field_type)) {
return throw_invalid_package_config();
}
switch (field_type) {
case simdjson::ondemand::json_type::object: {
if (value.raw_json().get(field_value)) {
return throw_invalid_package_config();
}
package_config.scripts = field_value;
break;
}
default:
break;
}
} }
} }
// package_config could be quite large, so we should move it instead of // package_config could be quite large, so we should move it instead of
@ -344,6 +360,28 @@ void BindingData::GetNearestParentPackageJSONType(
args.GetReturnValue().Set(Array::New(realm->isolate(), values, 3)); args.GetReturnValue().Set(Array::New(realm->isolate(), values, 3));
} }
void BindingData::GetPackageJSONScripts(
const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
std::string_view path = "package.json";
THROW_IF_INSUFFICIENT_PERMISSIONS(
realm->env(), permission::PermissionScope::kFileSystemRead, path);
auto package_json = GetPackageJSON(realm, path);
if (package_json == nullptr) {
printf("Can't read package.json\n");
return;
} else if (!package_json->scripts.has_value()) {
printf("Can't read package.json \"scripts\" object\n");
return;
}
args.GetReturnValue().Set(
ToV8Value(realm->context(), package_json->scripts.value())
.ToLocalChecked());
}
void BindingData::GetPackageScopeConfig( void BindingData::GetPackageScopeConfig(
const FunctionCallbackInfo<Value>& args) { const FunctionCallbackInfo<Value>& args) {
CHECK_GE(args.Length(), 1); CHECK_GE(args.Length(), 1);
@ -424,6 +462,7 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
"getNearestParentPackageJSON", "getNearestParentPackageJSON",
GetNearestParentPackageJSON); GetNearestParentPackageJSON);
SetMethod(isolate, target, "getPackageScopeConfig", GetPackageScopeConfig); SetMethod(isolate, target, "getPackageScopeConfig", GetPackageScopeConfig);
SetMethod(isolate, target, "getPackageJSONScripts", GetPackageJSONScripts);
} }
void BindingData::CreatePerContextProperties(Local<Object> target, void BindingData::CreatePerContextProperties(Local<Object> target,
@ -440,6 +479,7 @@ void BindingData::RegisterExternalReferences(
registry->Register(GetNearestParentPackageJSONType); registry->Register(GetNearestParentPackageJSONType);
registry->Register(GetNearestParentPackageJSON); registry->Register(GetNearestParentPackageJSON);
registry->Register(GetPackageScopeConfig); registry->Register(GetPackageScopeConfig);
registry->Register(GetPackageJSONScripts);
} }
} // namespace modules } // namespace modules

View file

@ -32,6 +32,7 @@ class BindingData : public SnapshotableObject {
std::string type = "none"; std::string type = "none";
std::optional<std::string> exports; std::optional<std::string> exports;
std::optional<std::string> imports; std::optional<std::string> imports;
std::optional<std::string> scripts;
std::string raw_json; std::string raw_json;
v8::Local<v8::Array> Serialize(Realm* realm) const; v8::Local<v8::Array> Serialize(Realm* realm) const;
@ -60,6 +61,8 @@ class BindingData : public SnapshotableObject {
const v8::FunctionCallbackInfo<v8::Value>& args); const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetPackageScopeConfig( static void GetPackageScopeConfig(
const v8::FunctionCallbackInfo<v8::Value>& args); const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetPackageJSONScripts(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void CreatePerIsolateProperties(IsolateData* isolate_data, static void CreatePerIsolateProperties(IsolateData* isolate_data,
v8::Local<v8::ObjectTemplate> ctor); v8::Local<v8::ObjectTemplate> ctor);

View file

@ -573,6 +573,9 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::prof_process); &EnvironmentOptions::prof_process);
// Options after --prof-process are passed through to the prof processor. // Options after --prof-process are passed through to the prof processor.
AddAlias("--prof-process", { "--prof-process", "--" }); AddAlias("--prof-process", { "--prof-process", "--" });
AddOption("--run",
"Run a script specified in package.json",
&EnvironmentOptions::run);
#if HAVE_INSPECTOR #if HAVE_INSPECTOR
AddOption("--cpu-prof", AddOption("--cpu-prof",
"Start the V8 CPU profiler on start up, and write the CPU profile " "Start the V8 CPU profiler on start up, and write the CPU profile "

View file

@ -161,6 +161,7 @@ class EnvironmentOptions : public Options {
bool heap_prof = false; bool heap_prof = false;
#endif // HAVE_INSPECTOR #endif // HAVE_INSPECTOR
std::string redirect_warnings; std::string redirect_warnings;
std::string run;
std::string diagnostic_dir; std::string diagnostic_dir;
std::string env_file; std::string env_file;
bool has_env_file_string = false; bool has_env_file_string = false;

1
test/fixtures/run-script/.env vendored Normal file
View file

@ -0,0 +1 @@
CUSTOM_ENV="hello world"

2
test/fixtures/run-script/node_modules/.bin/ada generated vendored Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
echo "06062023"

1
test/fixtures/run-script/node_modules/.bin/ada.bat generated vendored Executable file
View file

@ -0,0 +1 @@
@echo "06062023"

2
test/fixtures/run-script/node_modules/.bin/custom-env generated vendored Executable file
View file

@ -0,0 +1,2 @@
#!/bin/bash
echo "$CUSTOM_ENV"

1
test/fixtures/run-script/node_modules/.bin/custom-env.bat generated vendored Executable file
View file

@ -0,0 +1 @@
echo %CUSTOM_ENV%

2
test/fixtures/run-script/node_modules/.bin/positional-args generated vendored Executable file
View file

@ -0,0 +1,2 @@
#!/bin/bash
echo $@

View file

@ -0,0 +1,2 @@
@shift
@echo %*

11
test/fixtures/run-script/package.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"ada": "ada",
"ada-windows": "ada.bat",
"positional-args": "positional-args",
"positional-args-windows": "positional-args.bat",
"custom-env": "custom-env",
"custom-env-windows": "custom-env.bat"
}
}

View file

@ -0,0 +1,14 @@
'use strict';
require('../common');
const assert = require('node:assert').strict;
const childProcess = require('node:child_process');
const fixtures = require('../common/fixtures');
const child = childProcess.spawnSync(
process.execPath,
[ '--run', 'non-existent-command'],
{ cwd: fixtures.path('run-script'), encoding: 'utf8' },
);
assert.strictEqual(child.status, 1);
console.log(child.stderr);

View file

@ -0,0 +1,10 @@
Missing script: "non-existent-command"
Available scripts are:
test: echo "Error: no test specified" && exit 1
ada: ada
ada-windows: ada.bat
positional-args: positional-args
positional-args-windows: positional-args.bat
custom-env: custom-env
custom-env-windows: custom-env.bat

View file

@ -0,0 +1,99 @@
// Flags: --expose-internals
'use strict';
const common = require('../common');
const { it, describe } = require('node:test');
const assert = require('node:assert');
const fixtures = require('../common/fixtures');
const envSuffix = common.isWindows ? '-windows' : '';
describe('node run [command]', () => {
it('should emit experimental warning', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ '--run', 'test'],
{ cwd: __dirname },
);
assert.match(child.stderr, /ExperimentalWarning: Task runner is an experimental feature and might change at any time/);
assert.match(child.stdout, /Can't read package\.json/);
assert.strictEqual(child.code, 1);
});
it('returns error on non-existent file', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ '--no-warnings', '--run', 'test'],
{ cwd: __dirname },
);
assert.match(child.stdout, /Can't read package\.json/);
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 1);
});
it('runs a valid command', async () => {
// Run a script that just log `no test specified`
const child = await common.spawnPromisified(
process.execPath,
[ '--run', 'test', '--no-warnings'],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /Error: no test specified/);
assert.strictEqual(child.code, 1);
});
it('adds node_modules/.bin to path', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ '--no-warnings', '--run', `ada${envSuffix}`],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /06062023/);
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
});
it('appends positional arguments', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ '--no-warnings', '--run', `positional-args${envSuffix}`, '--', '--help "hello world test"'],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /--help "hello world test"/);
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
});
it('should support having --env-file cli flag', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ '--no-warnings', `--env-file=${fixtures.path('run-script/.env')}`, '--run', `custom-env${envSuffix}`],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /hello world/);
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
});
it('should properly escape shell', async () => {
const { escapeShell } = require('internal/shell');
const expectations = [
['', '\'\''],
['test', 'test'],
['test words', '\'test words\''],
['$1', '\'$1\''],
['"$1"', '\'"$1"\''],
['\'$1\'', '\\\'\'$1\'\\\''],
['\\$1', '\'\\$1\''],
['--arg="$1"', '\'--arg="$1"\''],
['--arg=node exec -c "$1"', '\'--arg=node exec -c "$1"\''],
['--arg=node exec -c \'$1\'', '\'--arg=node exec -c \'\\\'\'$1\'\\\''],
['\'--arg=node exec -c "$1"\'', '\\\'\'--arg=node exec -c "$1"\'\\\''],
];
for (const [input, expectation] of expectations) {
assert.strictEqual(escapeShell(input), expectation);
}
});
});

View file

@ -26,4 +26,5 @@ export interface ModulesBinding {
string, // raw content string, // raw content
] ]
getPackageScopeConfig(path: string): SerializedPackageConfig | undefined getPackageScopeConfig(path: string): SerializedPackageConfig | undefined
getPackageJSONScripts(): string | undefined
} }