Skip to content

Commit 8f4567d

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

9 files changed

Lines changed: 130 additions & 22 deletions

File tree

doc/api/cli.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2657,6 +2657,9 @@ forked processes, or clustered processes.
26572657
<!-- YAML
26582658
added: v22.0.0
26592659
changes:
2660+
- version: REPLACEME
2661+
pr-url: https://github.com/nodejs/node/pull/64606
2662+
description: Passing `--run` without a command lists the available scripts.
26602663
- version: v22.3.0
26612664
pr-url: https://github.com/nodejs/node/pull/53032
26622665
description: NODE_RUN_SCRIPT_NAME environment variable is added.
@@ -2673,6 +2676,14 @@ changes:
26732676
This runs a specified command from a package.json's `"scripts"` object.
26742677
If a missing `"command"` is provided, it will list the available scripts.
26752678

2679+
Passing `--run` without a command lists the available scripts and exits:
2680+
2681+
```console
2682+
$ node --run
2683+
Available scripts are:
2684+
test: node --test
2685+
```
2686+
26762687
`--run` will traverse up to the root directory and finds a `package.json`
26772688
file to run the command from.
26782689

doc/node.1

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,6 +1318,12 @@ forked processes, or clustered processes.
13181318
.It Fl -run
13191319
This runs a specified command from a package.json's \fB"scripts"\fR object.
13201320
If a missing \fB"command"\fR is provided, it will list the available scripts.
1321+
Passing \fB--run\fR without a command lists the available scripts and exits:
1322+
.Bd -literal
1323+
$ node --run
1324+
Available scripts are:
1325+
test: node --test
1326+
.Ed
13211327
\fB--run\fR will traverse up to the root directory and finds a \fBpackage.json\fR
13221328
file to run the command from.
13231329
\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

@@ -453,18 +454,21 @@ void OptionsParser<Options>::Parse(
453454

454455
std::string value;
455456
if (info.type != kBoolean && info.type != kNoOp && info.type != kV8Option) {
457+
// `--run` may be passed without a script name to list available scripts,
458+
// so an omitted value is not an error and must not swallow a later flag.
459+
const bool optional_value = name == "--run";
456460
if (equals_index != std::string::npos) {
457461
value = arg.substr(equals_index + 1);
458-
if (value.empty()) {
462+
if (value.empty() && !optional_value) {
459463
missing_argument();
460464
break;
461465
}
462-
} else {
463-
if (args.empty()) {
466+
} else if (args.empty() || (optional_value && args.first()[0] == '-')) {
467+
if (!optional_value) {
464468
missing_argument();
465469
break;
466470
}
467-
471+
} else {
468472
value = args.pop_first();
469473

470474
if (!value.empty() && value[0] == '-') {
@@ -512,6 +516,12 @@ void OptionsParser<Options>::Parse(
512516
default:
513517
UNREACHABLE();
514518
}
519+
520+
// Record that `--run` was seen so an empty value can be distinguished from
521+
// the option being absent. Guarded so it only compiles for the owning type.
522+
if constexpr (std::is_same_v<Options, PerProcessOptions>) {
523+
if (name == "--run") options->has_run = true;
524+
}
515525
}
516526
options->CheckOptions(errors, orig_args);
517527
}

src/node_options.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,9 @@ class PerProcessOptions : public Options {
355355
bool print_version = false;
356356
std::string experimental_sea_config;
357357
std::string run;
358+
// Tracks whether `--run` was passed, since an empty `run` is ambiguous
359+
// between "not passed" and "passed without a script name" (lists scripts).
360+
bool has_run = false;
358361

359362
std::string build_sea;
360363
#ifdef NODE_HAVE_I18N_SUPPORT

src/node_task_runner.cc

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,27 @@ FindPackageJson(const std::filesystem::path& cwd) {
249249
return {{package_json_path, raw_content, path_env_var}};
250250
}
251251

252+
// Prints every "name: command" pair in the scripts object to the given stream.
253+
static void PrintScripts(FILE* out,
254+
simdjson::ondemand::object& scripts_object) {
255+
// Reset the object to iterate from the beginning, in case it was read before.
256+
scripts_object.reset();
257+
simdjson::ondemand::value value;
258+
for (auto field : scripts_object) {
259+
std::string_view key_str;
260+
std::string_view value_str;
261+
if (!field.unescaped_key().get(key_str) && !field.value().get(value) &&
262+
!value.get_string().get(value_str)) {
263+
fprintf(out,
264+
" %.*s: %.*s\n",
265+
static_cast<int>(key_str.size()),
266+
key_str.data(),
267+
static_cast<int>(value_str.size()),
268+
value_str.data());
269+
}
270+
}
271+
}
272+
252273
void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
253274
std::string_view command_id,
254275
const std::vector<std::string_view>& positional_args) {
@@ -300,6 +321,15 @@ void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
300321
return;
301322
}
302323

324+
// With no command (e.g. bare `node --run`), list the available scripts to
325+
// stdout and exit successfully, mirroring `npm run`.
326+
if (command_id.empty()) {
327+
fprintf(stdout, "Available scripts are:\n");
328+
PrintScripts(stdout, scripts_object);
329+
result->exit_code_ = ExitCode::kNoFailure;
330+
return;
331+
}
332+
303333
// If the command_id is not found in the scripts object, throw an error.
304334
std::string_view command;
305335
if (auto command_error =
@@ -317,23 +347,7 @@ void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
317347
command_id.data(),
318348
path.string().c_str());
319349
fprintf(stderr, "Available scripts are:\n");
320-
321-
// Reset the object to iterate over it again
322-
scripts_object.reset();
323-
simdjson::ondemand::value value;
324-
for (auto field : scripts_object) {
325-
std::string_view key_str;
326-
std::string_view value_str;
327-
if (!field.unescaped_key().get(key_str) && !field.value().get(value) &&
328-
!value.get_string().get(value_str)) {
329-
fprintf(stderr,
330-
" %.*s: %.*s\n",
331-
static_cast<int>(key_str.size()),
332-
key_str.data(),
333-
static_cast<int>(value_str.size()),
334-
value_str.data());
335-
}
336-
}
350+
PrintScripts(stderr, scripts_object);
337351
}
338352
result->exit_code_ = ExitCode::kGenericUserError;
339353
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, 0);
14+
console.log(child.stdout);

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
@@ -222,4 +222,39 @@ describe('node --run [command]', () => {
222222
assert.strictEqual(child.stdout, '');
223223
assert.strictEqual(child.code, 1);
224224
});
225+
226+
it('lists available scripts to stdout when no command is given', async () => {
227+
const child = await common.spawnPromisified(
228+
process.execPath,
229+
[ '--run'],
230+
{ cwd: fixtures.path('run-script') },
231+
);
232+
assert.match(child.stdout, /Available scripts are:/);
233+
assert.match(child.stdout, /test: echo "Error: no test specified" && exit 1/);
234+
assert.strictEqual(child.stderr, '');
235+
assert.strictEqual(child.code, 0);
236+
});
237+
238+
it('does not consume a following flag as the script name', async () => {
239+
// `--run` followed by a flag lists scripts rather than treating the flag
240+
// as a script name.
241+
const child = await common.spawnPromisified(
242+
process.execPath,
243+
[ '--run', '--no-warnings'],
244+
{ cwd: fixtures.path('run-script') },
245+
);
246+
assert.match(child.stdout, /Available scripts are:/);
247+
assert.strictEqual(child.code, 0);
248+
});
249+
250+
it('errors when listing scripts without a package.json', async () => {
251+
const child = await common.spawnPromisified(
252+
process.execPath,
253+
[ '--run'],
254+
{ cwd: __dirname },
255+
);
256+
assert.match(child.stderr, /Can't find package\.json/);
257+
assert.strictEqual(child.stdout, '');
258+
assert.strictEqual(child.code, 1);
259+
});
225260
});

0 commit comments

Comments
 (0)