Skip to content

Commit

Permalink
cli: implement node run <script-in-package-json>
Browse files Browse the repository at this point in the history
  • Loading branch information
anonrig committed Mar 23, 2024
1 parent d1d5da2 commit dae84d3
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 0 deletions.
22 changes: 22 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ 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]`

<!-- YAML
added: REPLACEME
-->

This runs an arbitrary command from a package.json's `"scripts"` object.
If no `"command"` is provided, it will list the available scripts.

By default, this command adds the current path appended by
`node_modules/.bin` directory to the PATH in order to execute the binaries
from dependencies.

For example, the following command will run the `start-server` script of
the current project's `package.json`:

```console
$ node run start-server
```

## Options

<!-- YAML
Expand Down
61 changes: 61 additions & 0 deletions lib/internal/main/run.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'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);
const id = process.argv.at(2);
const 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);
}

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): Add support for appending the bin path of all parent folders.
const paths = [binPath, PATH].join(delimiter);
for (const key of keys) {
env[key] = paths;
}
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
41 changes: 41 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,30 @@ 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("Package.json scripts doesn't exist or parseable\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 +463,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 +480,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 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
}

0 comments on commit dae84d3

Please sign in to comment.