Skip to content

Commit 2958891

Browse files
committed
src: list scripts when --run has no command
Signed-off-by: James Ross <james@jross.me>
1 parent e2b33e2 commit 2958891

9 files changed

Lines changed: 133 additions & 22 deletions

File tree

doc/api/cli.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2685,6 +2685,9 @@ forked processes, or clustered processes.
26852685
<!-- YAML
26862686
added: v22.0.0
26872687
changes:
2688+
- version: REPLACEME
2689+
pr-url: https://github.com/nodejs/node/pull/64606
2690+
description: Passing `--run` without a command lists the available scripts.
26882691
- version: v22.3.0
26892692
pr-url: https://github.com/nodejs/node/pull/53032
26902693
description: NODE_RUN_SCRIPT_NAME environment variable is added.
@@ -2701,6 +2704,15 @@ changes:
27012704
This runs a specified command from a package.json's `"scripts"` object.
27022705
If a missing `"command"` is provided, it will list the available scripts.
27032706

2707+
Passing `--run` without a command lists the available scripts and exits
2708+
with a non-zero exit code:
2709+
2710+
```console
2711+
$ node --run
2712+
Available scripts are:
2713+
test: node --test
2714+
```
2715+
27042716
`--run` will traverse up to the root directory and finds a `package.json`
27052717
file to run the command from.
27062718

doc/node.1

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,6 +1332,13 @@ forked processes, or clustered processes.
13321332
.It Fl -run
13331333
This runs a specified command from a package.json's \fB"scripts"\fR object.
13341334
If a missing \fB"command"\fR is provided, it will list the available scripts.
1335+
Passing \fB--run\fR without a command lists the available scripts and exits
1336+
with a non-zero exit code:
1337+
.Bd -literal
1338+
$ node --run
1339+
Available scripts are:
1340+
test: node --test
1341+
.Ed
13351342
\fB--run\fR will traverse up to the root directory and finds a \fBpackage.json\fR
13361343
file to run the command from.
13371344
\fB--run\fR prepends \fB./node_modules/.bin\fR for each ancestor of

src/node.cc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,8 @@ InitializeOncePerProcessInternal(const std::vector<std::string>& args,
11331133
}
11341134
}
11351135

1136-
if (!per_process::cli_options->run.empty()) {
1136+
// A bare `--run` (empty value) lists the available scripts; a value runs it.
1137+
if (per_process::cli_options->has_run) {
11371138
auto positional_args = task_runner::GetPositionalArgs(args);
11381139
result->early_return_ = true;
11391140
task_runner::RunTask(

src/node_options-inl.h

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include <algorithm>
77
#include <cstdlib>
88
#include <ranges>
9+
#include <type_traits>
910
#include "node_options.h"
1011
#include "util.h"
1112

@@ -462,18 +463,21 @@ void OptionsParser<Options>::Parse(
462463

463464
std::string value;
464465
if (info.type != kBoolean && info.type != kNoOp && info.type != kV8Option) {
466+
// `--run` may be passed without a script name to list available scripts,
467+
// so an omitted value is not an error and must not swallow a later flag.
468+
const bool optional_value = name == "--run";
465469
if (equals_index != std::string::npos) {
466470
value = arg.substr(equals_index + 1);
467-
if (value.empty()) {
471+
if (value.empty() && !optional_value) {
468472
missing_argument();
469473
break;
470474
}
471-
} else {
472-
if (args.empty()) {
475+
} else if (args.empty() || (optional_value && args.first()[0] == '-')) {
476+
if (!optional_value) {
473477
missing_argument();
474478
break;
475479
}
476-
480+
} else {
477481
value = args.pop_first();
478482

479483
if (!value.empty() && value[0] == '-') {
@@ -521,6 +525,12 @@ void OptionsParser<Options>::Parse(
521525
default:
522526
UNREACHABLE();
523527
}
528+
529+
// Record that `--run` was seen so an empty value can be distinguished from
530+
// the option being absent. Guarded so it only compiles for the owning type.
531+
if constexpr (std::is_same_v<Options, PerProcessOptions>) {
532+
if (name == "--run") options->has_run = true;
533+
}
524534
}
525535
options->CheckOptions(errors, orig_args);
526536
}

src/node_options.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,9 @@ class PerProcessOptions : public Options {
420420
DEFINE_BOOL_FIELD(report_on_fatalerror) = false;
421421
DEFINE_BOOL_FIELD(report_compact) = false;
422422
DEFINE_BOOL_FIELD(trace_sigint) = false;
423+
// Tracks whether `--run` was passed, since an empty `run` is ambiguous
424+
// between "not passed" and "passed without a script name" (lists scripts).
425+
DEFINE_BOOL_FIELD(has_run) = false;
423426

424427
inline PerIsolateOptions* get_per_isolate_options();
425428
void CheckOptions(std::vector<std::string>* errors,

src/node_task_runner.cc

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,27 @@ FindPackageJson(const std::filesystem::path& cwd) {
254254
return {{package_json_path, raw_content, path_env_var}};
255255
}
256256

257+
// Prints every "name: command" pair in the scripts object to the given stream.
258+
static void PrintScripts(FILE* out,
259+
simdjson::ondemand::object& scripts_object) {
260+
// Reset the object to iterate from the beginning, in case it was read before.
261+
scripts_object.reset();
262+
simdjson::ondemand::value value;
263+
for (auto field : scripts_object) {
264+
std::string_view key_str;
265+
std::string_view value_str;
266+
if (!field.unescaped_key().get(key_str) && !field.value().get(value) &&
267+
!value.get_string().get(value_str)) {
268+
fprintf(out,
269+
" %.*s: %.*s\n",
270+
static_cast<int>(key_str.size()),
271+
key_str.data(),
272+
static_cast<int>(value_str.size()),
273+
value_str.data());
274+
}
275+
}
276+
}
277+
257278
void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
258279
std::string_view command_id,
259280
const std::vector<std::string_view>& positional_args) {
@@ -306,6 +327,16 @@ void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
306327
return;
307328
}
308329

330+
// With no command (e.g. bare `node --run`), list the available scripts but
331+
// exit non-zero so a script invoking `node --run $CMD` with an unset
332+
// variable still fails, as it did before bare `--run` was allowed.
333+
if (command_id.empty()) {
334+
fprintf(stderr, "Available scripts are:\n");
335+
PrintScripts(stderr, scripts_object);
336+
result->exit_code_ = ExitCode::kInvalidCommandLineArgument;
337+
return;
338+
}
339+
309340
// If the command_id is not found in the scripts object, throw an error.
310341
std::string_view command;
311342
if (auto command_error =
@@ -323,23 +354,7 @@ void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
323354
command_id.data(),
324355
ConvertPathToUTF8(path).c_str());
325356
fprintf(stderr, "Available scripts are:\n");
326-
327-
// Reset the object to iterate over it again
328-
scripts_object.reset();
329-
simdjson::ondemand::value value;
330-
for (auto field : scripts_object) {
331-
std::string_view key_str;
332-
std::string_view value_str;
333-
if (!field.unescaped_key().get(key_str) && !field.value().get(value) &&
334-
!value.get_string().get(value_str)) {
335-
fprintf(stderr,
336-
" %.*s: %.*s\n",
337-
static_cast<int>(key_str.size()),
338-
key_str.data(),
339-
static_cast<int>(value_str.size()),
340-
value_str.data());
341-
}
342-
}
357+
PrintScripts(stderr, scripts_object);
343358
}
344359
result->exit_code_ = ExitCode::kGenericUserError;
345360
return;

test/message/node_run_list.js

Lines changed: 14 additions & 0 deletions
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+
[ '--no-warnings', '--run'],
11+
{ cwd: fixtures.path('run-script'), encoding: 'utf8' },
12+
);
13+
assert.strictEqual(child.status, 9);
14+
console.log(child.stderr);

test/message/node_run_list.out

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
Available scripts are:
2+
test: echo "Error: no test specified" && exit 1
3+
ada: ada
4+
ada-windows: ada.bat
5+
positional-args: positional-args
6+
positional-args-windows: positional-args.bat
7+
custom-env: custom-env
8+
custom-env-windows: custom-env.bat
9+
path-env: path-env
10+
path-env-windows: path-env.bat
11+
special-env-variables: special-env-variables
12+
special-env-variables-windows: special-env-variables.bat
13+
pwd: pwd
14+
pwd-windows: cd

test/parallel/test-node-run.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,4 +301,39 @@ describe('node --run [command]', { concurrency: !process.env.TEST_PARALLEL }, ()
301301
assert.strictEqual(child.stderr, '');
302302
assert.strictEqual(child.code, 0);
303303
});
304+
305+
it('lists available scripts when no command is given', async () => {
306+
const child = await common.spawnPromisified(
307+
process.execPath,
308+
[ '--run'],
309+
{ cwd: fixtures.path('run-script') },
310+
);
311+
assert.match(child.stderr, /Available scripts are:/);
312+
assert.match(child.stderr, /test: echo "Error: no test specified" && exit 1/);
313+
assert.strictEqual(child.stdout, '');
314+
assert.strictEqual(child.code, 9);
315+
});
316+
317+
it('does not consume a following flag as the script name', async () => {
318+
// `--run` followed by a flag lists scripts rather than treating the flag
319+
// as a script name.
320+
const child = await common.spawnPromisified(
321+
process.execPath,
322+
[ '--run', '--no-warnings'],
323+
{ cwd: fixtures.path('run-script') },
324+
);
325+
assert.match(child.stderr, /Available scripts are:/);
326+
assert.strictEqual(child.code, 9);
327+
});
328+
329+
it('errors when listing scripts without a package.json', async () => {
330+
const child = await common.spawnPromisified(
331+
process.execPath,
332+
[ '--run'],
333+
{ cwd: __dirname },
334+
);
335+
assert.match(child.stderr, /Can't find package\.json/);
336+
assert.strictEqual(child.stdout, '');
337+
assert.strictEqual(child.code, 1);
338+
});
304339
});

0 commit comments

Comments
 (0)