Skip to content

Commit 5a41bcf

Browse files
anonrigRafaelGSS
authored andcommitted
src: traverse parent folders while running --run
PR-URL: #53154 Reviewed-By: Benjamin Gruenbaum <[email protected]> Reviewed-By: Daniel Lemire <[email protected]> Reviewed-By: James M Snell <[email protected]>
1 parent 133cae0 commit 5a41bcf

File tree

6 files changed

+130
-73
lines changed

6 files changed

+130
-73
lines changed

doc/api/cli.md

+12-6
Original file line numberDiff line numberDiff line change
@@ -1957,16 +1957,25 @@ changes:
19571957
- version: REPLACEME
19581958
pr-url: https://github.com/nodejs/node/pull/53058
19591959
description: NODE_RUN_PACKAGE_JSON_PATH environment variable is added.
1960+
- version: REPLACEME
1961+
pr-url: https://github.com/nodejs/node/pull/53154
1962+
description: Traverses up to the root directory and finds
1963+
a `package.json` file to run the command from, and updates
1964+
`PATH` environment variable accordingly.
19601965
-->
19611966

19621967
> Stability: 1.1 - Active development
19631968
19641969
This runs a specified command from a package.json's `"scripts"` object.
19651970
If no `"command"` is provided, it will list the available scripts.
19661971

1967-
`--run` prepends `./node_modules/.bin`, relative to the current
1968-
working directory, to the `PATH` in order to execute the binaries from
1969-
dependencies.
1972+
`--run` will traverse up to the root directory and finds a `package.json`
1973+
file to run the command from.
1974+
1975+
`--run` prepends `./node_modules/.bin` for each ancestor of
1976+
the current directory, to the `PATH` in order to execute the binaries from
1977+
different folders where multiple `node_modules` directories are present, if
1978+
`ancestor-folder/node_modules/.bin` is a directory.
19701979

19711980
For example, the following command will run the `test` script of
19721981
the `package.json` in the current folder:
@@ -1991,9 +2000,6 @@ cases.
19912000
Some features of other `run` implementations that are intentionally excluded
19922001
are:
19932002

1994-
* Searching for `package.json` files outside the current folder.
1995-
* Prepending the `.bin` or `node_modules/.bin` paths of folders outside the
1996-
current folder.
19972003
* Running `pre` or `post` scripts in addition to the specified script.
19982004
* Defining package manager-specific environment variables.
19992005

src/node_task_runner.cc

+64-52
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,28 @@
11
#include "node_task_runner.h"
22
#include "util.h"
33

4-
#include <filesystem>
54
#include <regex> // NOLINT(build/c++11)
65

76
namespace node::task_runner {
87

98
#ifdef _WIN32
10-
static constexpr const char* bin_path = "\\node_modules\\.bin";
9+
static constexpr const char* env_var_separator = ";";
1110
#else
12-
static constexpr const char* bin_path = "/node_modules/.bin";
11+
static constexpr const char* env_var_separator = ":";
1312
#endif // _WIN32
1413

1514
ProcessRunner::ProcessRunner(std::shared_ptr<InitializationResultImpl> result,
16-
std::string_view package_json_path,
15+
const std::filesystem::path& package_json_path,
1716
std::string_view script_name,
1817
std::string_view command,
19-
const PositionalArgs& positional_args) {
18+
std::string_view path_env_var,
19+
const PositionalArgs& positional_args)
20+
: init_result(std::move(result)),
21+
package_json_path_(package_json_path),
22+
script_name_(script_name),
23+
path_env_var_(path_env_var) {
2024
memset(&options_, 0, sizeof(uv_process_options_t));
2125

22-
// Get the current working directory.
23-
char cwd[PATH_MAX_BYTES];
24-
size_t cwd_size = PATH_MAX_BYTES;
25-
CHECK_EQ(uv_cwd(cwd, &cwd_size), 0);
26-
CHECK_GT(cwd_size, 0);
27-
28-
#ifdef _WIN32
29-
std::string current_bin_path = cwd + std::string(bin_path) + ";";
30-
#else
31-
std::string current_bin_path = cwd + std::string(bin_path) + ":";
32-
#endif // _WIN32
33-
3426
// Inherit stdin, stdout, and stderr from the parent process.
3527
options_.stdio_count = 3;
3628
child_stdio[0].flags = UV_INHERIT_FD;
@@ -46,18 +38,13 @@ ProcessRunner::ProcessRunner(std::shared_ptr<InitializationResultImpl> result,
4638
options_.flags |= UV_PROCESS_WINDOWS_VERBATIM_ARGUMENTS;
4739
#endif
4840

49-
init_result = std::move(result);
50-
5141
// Set the process handle data to this class instance.
5242
// This is used to access the class instance from the OnExit callback.
5343
// It is required because libuv doesn't allow passing lambda functions as a
5444
// callback.
5545
process_.data = this;
5646

57-
SetEnvironmentVariables(current_bin_path,
58-
std::string_view(cwd, cwd_size),
59-
package_json_path,
60-
script_name);
47+
SetEnvironmentVariables();
6148

6249
std::string command_str(command);
6350

@@ -73,12 +60,7 @@ ProcessRunner::ProcessRunner(std::shared_ptr<InitializationResultImpl> result,
7360
}
7461

7562
#ifdef _WIN32
76-
// We check whether file_ ends with cmd.exe in a case-insensitive manner.
77-
// C++20 provides ends_with, but we roll our own for compatibility.
78-
const char* cmdexe = "cmd.exe";
79-
if (file_.size() >= strlen(cmdexe) &&
80-
StringEqualNoCase(cmdexe,
81-
file_.c_str() + file_.size() - strlen(cmdexe))) {
63+
if (file_.ends_with("cmd.exe")) {
8264
// If the file is cmd.exe, use the following command line arguments:
8365
// "/c" Carries out the command and exit.
8466
// "/d" Disables execution of AutoRun commands.
@@ -106,10 +88,7 @@ ProcessRunner::ProcessRunner(std::shared_ptr<InitializationResultImpl> result,
10688
options_.args[argc] = nullptr;
10789
}
10890

109-
void ProcessRunner::SetEnvironmentVariables(const std::string& current_bin_path,
110-
std::string_view cwd,
111-
std::string_view package_json_path,
112-
std::string_view script_name) {
91+
void ProcessRunner::SetEnvironmentVariables() {
11392
uv_env_item_t* env_items;
11493
int env_count;
11594
CHECK_EQ(0, uv_os_environ(&env_items, &env_count));
@@ -130,28 +109,21 @@ void ProcessRunner::SetEnvironmentVariables(const std::string& current_bin_path,
130109
#endif // _WIN32
131110

132111
if (StringEqualNoCase(name.c_str(), "path")) {
133-
// Add bin_path to the beginning of the PATH
134-
value = current_bin_path + value;
112+
// Add path env variable to the beginning of the PATH
113+
value = path_env_var_ + value;
135114
}
136115
env_vars_.push_back(name + "=" + value);
137116
}
138117
uv_os_free_environ(env_items, env_count);
139118

140119
// Add NODE_RUN_SCRIPT_NAME environment variable to the environment
141120
// to indicate which script is being run.
142-
env_vars_.push_back("NODE_RUN_SCRIPT_NAME=" + std::string(script_name));
121+
env_vars_.push_back("NODE_RUN_SCRIPT_NAME=" + script_name_);
143122

144123
// Add NODE_RUN_PACKAGE_JSON_PATH environment variable to the environment to
145124
// indicate which package.json is being processed.
146-
if (std::filesystem::path(package_json_path).is_absolute()) {
147-
// TODO(anonrig): Traverse up the directory tree until we find a
148-
// package.json
149-
env_vars_.push_back("NODE_RUN_PACKAGE_JSON_PATH=" +
150-
std::string(package_json_path));
151-
} else {
152-
auto path = std::filesystem::path(cwd) / std::string(package_json_path);
153-
env_vars_.push_back("NODE_RUN_PACKAGE_JSON_PATH=" + path.string());
154-
}
125+
env_vars_.push_back("NODE_RUN_PACKAGE_JSON_PATH=" +
126+
package_json_path_.string());
155127

156128
env = std::unique_ptr<char*[]>(new char*[env_vars_.size() + 1]);
157129
options_.env = env.get();
@@ -240,19 +212,58 @@ void ProcessRunner::Run() {
240212
uv_run(loop_, UV_RUN_DEFAULT);
241213
}
242214

215+
std::optional<std::tuple<std::filesystem::path, std::string, std::string>>
216+
FindPackageJson(const std::filesystem::path& cwd) {
217+
auto package_json_path = cwd / "package.json";
218+
std::string raw_content;
219+
std::string path_env_var;
220+
auto root_path = cwd.root_path();
221+
222+
for (auto directory_path = cwd;
223+
!std::filesystem::equivalent(root_path, directory_path);
224+
directory_path = directory_path.parent_path()) {
225+
// Append "path/node_modules/.bin" to the env var, if it is a directory.
226+
auto node_modules_bin = directory_path / "node_modules" / ".bin";
227+
if (std::filesystem::is_directory(node_modules_bin)) {
228+
path_env_var += node_modules_bin.string() + env_var_separator;
229+
}
230+
231+
if (raw_content.empty()) {
232+
package_json_path = directory_path / "package.json";
233+
// This is required for Windows because std::filesystem::path::c_str()
234+
// returns wchar_t* on Windows, and char* on other platforms.
235+
std::string contents = package_json_path.string();
236+
USE(ReadFileSync(&raw_content, contents.c_str()) > 0);
237+
}
238+
}
239+
240+
// This means that there is no package.json until the root directory.
241+
// In this case, we just return nullopt, which will terminate the process..
242+
if (raw_content.empty()) {
243+
return std::nullopt;
244+
}
245+
246+
return {{package_json_path, raw_content, path_env_var}};
247+
}
248+
243249
void RunTask(std::shared_ptr<InitializationResultImpl> result,
244250
std::string_view command_id,
245251
const std::vector<std::string_view>& positional_args) {
246-
std::string_view path = "package.json";
247-
std::string raw_json;
252+
auto cwd = std::filesystem::current_path();
253+
auto package_json = FindPackageJson(cwd);
248254

249-
// No need to exclude BOM since simdjson will skip it.
250-
if (ReadFileSync(&raw_json, path.data()) < 0) {
255+
if (!package_json.has_value()) {
251256
fprintf(stderr, "Can't read package.json\n");
252257
result->exit_code_ = ExitCode::kGenericUserError;
253258
return;
254259
}
255260

261+
// - path: Path to the package.json file.
262+
// - raw_json: Raw content of the package.json file.
263+
// - path_env_var: This represents the `PATH` environment variable.
264+
// It always ends with ";" or ":" depending on the platform.
265+
auto [path, raw_json, path_env_var] = *package_json;
266+
256267
simdjson::ondemand::parser json_parser;
257268
simdjson::ondemand::document document;
258269
simdjson::ondemand::object main_object;
@@ -302,8 +313,8 @@ void RunTask(std::shared_ptr<InitializationResultImpl> result,
302313
return;
303314
}
304315

305-
auto runner =
306-
ProcessRunner(result, path, command_id, command, positional_args);
316+
auto runner = ProcessRunner(
317+
result, path, command_id, command, path_env_var, positional_args);
307318
runner.Run();
308319
}
309320

@@ -317,10 +328,11 @@ PositionalArgs GetPositionalArgs(const std::vector<std::string>& args) {
317328
if (auto dash_dash = std::find(args.begin(), args.end(), "--");
318329
dash_dash != args.end()) {
319330
PositionalArgs positional_args{};
331+
positional_args.reserve(args.size() - (dash_dash - args.begin()));
320332
for (auto it = dash_dash + 1; it != args.end(); ++it) {
321333
// SAFETY: The following code is safe because the lifetime of the
322334
// arguments is guaranteed to be valid until the end of the task runner.
323-
positional_args.push_back(std::string_view(it->c_str(), it->size()));
335+
positional_args.emplace_back(it->c_str(), it->size());
324336
}
325337
return positional_args;
326338
}

src/node_task_runner.h

+31-11
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
#include "spawn_sync.h"
99
#include "uv.h"
1010

11+
#include <filesystem>
1112
#include <optional>
1213
#include <string_view>
14+
#include <tuple>
1315

14-
namespace node {
15-
namespace task_runner {
16+
namespace node::task_runner {
1617

1718
using PositionalArgs = std::vector<std::string_view>;
1819

@@ -23,9 +24,10 @@ using PositionalArgs = std::vector<std::string_view>;
2324
class ProcessRunner {
2425
public:
2526
ProcessRunner(std::shared_ptr<InitializationResultImpl> result,
26-
std::string_view package_json_path,
27+
const std::filesystem::path& package_json_path,
2728
std::string_view script_name,
28-
std::string_view command_id,
29+
std::string_view command,
30+
std::string_view path_env_var,
2931
const PositionalArgs& positional_args);
3032
void Run();
3133
static void ExitCallback(uv_process_t* req,
@@ -45,26 +47,44 @@ class ProcessRunner {
4547

4648
// OnExit is the callback function that is called when the process exits.
4749
void OnExit(int64_t exit_status, int term_signal);
48-
void SetEnvironmentVariables(const std::string& bin_path,
49-
std::string_view cwd,
50-
std::string_view package_json_path,
51-
std::string_view script_name);
50+
void SetEnvironmentVariables();
5251

5352
#ifdef _WIN32
5453
std::string file_ = "cmd.exe";
5554
#else
5655
std::string file_ = "/bin/sh";
5756
#endif // _WIN32
57+
58+
// Represents the absolute path to the package.json file.
59+
std::filesystem::path package_json_path_;
60+
// Represents the name of the script that is being run.
61+
std::string script_name_;
62+
// Represents PATH environment variable that contains
63+
// all subdirectory paths appended with node_modules/.bin suffix.
64+
std::string path_env_var_;
5865
};
5966

67+
// This function traverses up to the root directory.
68+
// While traversing up, if it finds a package.json file, it reads its content.
69+
// If it cannot find a package.json file, it returns std::nullopt.
70+
// Otherwise, it returns a tuple of:
71+
// - the path to the package.json file
72+
// - package.json file content
73+
// - `path_env_var` variable
74+
//
75+
// For example, on POSIX, it returns the following for `path_env_var`,
76+
// if the current directory is `/anonrig`:
77+
// `/anonrig/node_modules/.bin:/node_modules/.bin`
78+
std::optional<std::tuple<std::filesystem::path, std::string, std::string>>
79+
FindPackageJson(const std::filesystem::path& cwd);
80+
6081
void RunTask(std::shared_ptr<InitializationResultImpl> result,
6182
std::string_view command_id,
6283
const PositionalArgs& positional_args);
6384
PositionalArgs GetPositionalArgs(const std::vector<std::string>& args);
64-
std::string EscapeShell(const std::string_view command);
85+
std::string EscapeShell(std::string_view command);
6586

66-
} // namespace task_runner
67-
} // namespace node
87+
} // namespace node::task_runner
6888

6989
#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS
7090

test/fixtures/run-script/sub-directory/.gitkeep

Whitespace-only changes.

test/fixtures/run-script/sub-directory/node_modules/.bin/.gitkeep

Whitespace-only changes.

test/parallel/test-node-run.js

+23-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// Flags: --expose-internals
21
'use strict';
32

43
const common = require('../common');
@@ -8,7 +7,7 @@ const assert = require('node:assert');
87
const fixtures = require('../common/fixtures');
98
const envSuffix = common.isWindows ? '-windows' : '';
109

11-
describe('node run [command]', () => {
10+
describe('node --run [command]', () => {
1211
it('should emit experimental warning', async () => {
1312
const child = await common.spawnPromisified(
1413
process.execPath,
@@ -70,13 +69,21 @@ describe('node run [command]', () => {
7069
assert.strictEqual(child.code, 0);
7170
});
7271

73-
it('should set PATH environment variable to node_modules/.bin', async () => {
72+
it('should set PATH environment variable with paths appended with node_modules/.bin', async () => {
7473
const child = await common.spawnPromisified(
7574
process.execPath,
7675
[ '--no-warnings', '--run', `path-env${envSuffix}`],
77-
{ cwd: fixtures.path('run-script') },
76+
{ cwd: fixtures.path('run-script/sub-directory') },
7877
);
7978
assert.ok(child.stdout.includes(fixtures.path('run-script/node_modules/.bin')));
79+
80+
// The following test ensures that we do not add paths that does not contain
81+
// "node_modules/.bin"
82+
assert.ok(!child.stdout.includes(fixtures.path('node_modules/.bin')));
83+
84+
// The following test ensures that we add paths that contains "node_modules/.bin"
85+
assert.ok(child.stdout.includes(fixtures.path('run-script/sub-directory/node_modules/.bin')));
86+
8087
assert.strictEqual(child.stderr, '');
8188
assert.strictEqual(child.code, 0);
8289
});
@@ -94,4 +101,16 @@ describe('node run [command]', () => {
94101
assert.strictEqual(child.stderr, '');
95102
assert.strictEqual(child.code, 0);
96103
});
104+
105+
it('will search parent directories for a package.json file', async () => {
106+
const packageJsonPath = fixtures.path('run-script/package.json');
107+
const child = await common.spawnPromisified(
108+
process.execPath,
109+
[ '--no-warnings', '--run', `special-env-variables${envSuffix}`],
110+
{ cwd: fixtures.path('run-script/sub-directory') },
111+
);
112+
assert.ok(child.stdout.includes(packageJsonPath));
113+
assert.strictEqual(child.stderr, '');
114+
assert.strictEqual(child.code, 0);
115+
});
97116
});

0 commit comments

Comments
 (0)