Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,37 @@ entry point, the `node` command will accept as input only files with `.js`,
[`--experimental-wasm-modules`][] is enabled; and with no extension when
[`--experimental-default-type=module`][] is passed.

## Sub-commands

### `node run <command> [<arguments>]`

<!-- 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.

`node 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 the script
name will be appended to the script:

```console
$ node run test --verbose
```

## Options

<!-- YAML
Expand Down
71 changes: 71 additions & 0 deletions lib/internal/main/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'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');

prepareMainThreadExecution(false, false);

markBootstrapComplete();

// 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.exit(1);
return;
}
const scripts = JSON.parse(json_string);
// Remove the first two arguments, which are the node binary and the command "run"
const args = process.argv.slice(2);
const id = args.shift();
let command = scripts[id];

if (!command) {
const { error } = require('internal/console/global');

error(`Missing script: "${id}"`);

const keys = Object.keys(scripts);
if (keys.length === 0) {
error('There are no scripts available in package.json');
} else {
error('Available scripts are:\n');
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 += ' ' + args.map((arg) => arg.trim()).join(' ');
}
execSync(command, { stdio: 'inherit', env });
4 changes: 4 additions & 0 deletions src/node.cc
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,10 @@ MaybeLocal<Value> StartExecution(Environment* env, StartExecutionCallback cb) {
return StartExecution(env, "internal/main/watch_mode");
}

if (!first_argv.empty() && first_argv == "run") {
return StartExecution(env, "internal/main/run");
}

if (!first_argv.empty() && first_argv != "-") {
return StartExecution(env, "internal/main/run_main_module");
}
Expand Down
39 changes: 39 additions & 0 deletions src/node_modules.cc
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,21 @@ const BindingData::PackageConfig* BindingData::GetPackageJSON(
if (field_value == "commonjs" || field_value == "module") {
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
Expand Down Expand Up @@ -344,6 +359,28 @@ void BindingData::GetNearestParentPackageJSONType(
args.GetReturnValue().Set(Array::New(realm->isolate(), values, 3));
}

void BindingData::GetPackageJSONScripts(
const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
std::string 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(
const FunctionCallbackInfo<Value>& args) {
CHECK_GE(args.Length(), 1);
Expand Down Expand Up @@ -424,6 +461,7 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
"getNearestParentPackageJSON",
GetNearestParentPackageJSON);
SetMethod(isolate, target, "getPackageScopeConfig", GetPackageScopeConfig);
SetMethod(isolate, target, "getPackageJSONScripts", GetPackageJSONScripts);
}

void BindingData::CreatePerContextProperties(Local<Object> target,
Expand All @@ -440,6 +478,7 @@ void BindingData::RegisterExternalReferences(
registry->Register(GetNearestParentPackageJSONType);
registry->Register(GetNearestParentPackageJSON);
registry->Register(GetPackageScopeConfig);
registry->Register(GetPackageJSONScripts);
}

} // namespace modules
Expand Down
3 changes: 3 additions & 0 deletions src/node_modules.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class BindingData : public SnapshotableObject {
std::string type = "none";
std::optional<std::string> exports;
std::optional<std::string> imports;
std::optional<std::string> scripts;
std::string raw_json;

v8::Local<v8::Array> Serialize(Realm* realm) const;
Expand Down Expand Up @@ -60,6 +61,8 @@ class BindingData : public SnapshotableObject {
const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetPackageScopeConfig(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetPackageJSONScripts(
const v8::FunctionCallbackInfo<v8::Value>& args);

static void CreatePerIsolateProperties(IsolateData* isolate_data,
v8::Local<v8::ObjectTemplate> ctor);
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/run-script/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CUSTOM_ENV="hello world"
2 changes: 2 additions & 0 deletions test/fixtures/run-script/node_modules/.bin/ada

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/run-script/node_modules/.bin/ada.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions test/fixtures/run-script/node_modules/.bin/custom-env

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions test/fixtures/run-script/node_modules/.bin/custom-env.bat

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions test/fixtures/run-script/node_modules/.bin/positional-args

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions test/fixtures/run-script/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"ada": "ada",
"ada.bat": "ada.bat",
"positional-args": "positional-args",
"positional-args.bat": "positional-args.bat",
"custom-env": "custom-env",
"custom-env.bat": "custom-env.bat"
}
}
77 changes: 77 additions & 0 deletions test/parallel/test-node-run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use strict';

const common = require('../common');
const { it, describe } = require('node:test');
const assert = require('node:assert');

const fixtures = require('../common/fixtures');

describe('node run [command]', () => {
it('returns error on non-existent command', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ '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'],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /Error: no test specified/);
assert.strictEqual(child.code, 1);
});

let adabatch = 'ada';
if (common.isWindows) {
adabatch = 'ada.bat';
}

it('adds node_modules/.bin to path', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ 'run', adabatch],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /06062023/);
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
});

let posbatch = 'positional-args';
if (common.isWindows) {
posbatch = 'positional-args.bat';
}
it('appends positional arguments', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ 'run', posbatch, '--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);
});

let cusbatch = 'custom-env';
if (common.isWindows) {
cusbatch = 'custom-env.bat';
}
it('should support having --env-file cli flag', async () => {
const child = await common.spawnPromisified(
process.execPath,
[ `--env-file=${fixtures.path('run-script/.env')}`, 'run', cusbatch],
{ cwd: fixtures.path('run-script') },
);
assert.match(child.stdout, /hello world/);
assert.strictEqual(child.stderr, '');
assert.strictEqual(child.code, 0);
});
});
1 change: 1 addition & 0 deletions typings/internalBinding/modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export interface ModulesBinding {
string, // raw content
]
getPackageScopeConfig(path: string): SerializedPackageConfig | undefined
getPackageJSONScripts(): string | undefined
}