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
18 changes: 17 additions & 1 deletion doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,19 @@ added: v6.0.0
Enable FIPS-compliant crypto at startup. (Requires Node.js to be built
against FIPS-compatible OpenSSL.)

### `--enable-run-hooks`

<!-- YAML
added: REPLACEME
-->

When used together with [`--run`][], also run the matching `"pre<script>"`
lifecycle hook (if one exists in the `"scripts"` object) before the requested
script. For example, `node --run build --enable-run-hooks` runs `prebuild`
first, and only runs `build` if `prebuild` exits successfully. Positional
arguments passed after `--` are forwarded to the requested script only, not to
the hook. Without this flag, `--run` never runs lifecycle hooks.

### `--enable-source-maps`

<!-- YAML
Expand Down Expand Up @@ -2683,7 +2696,8 @@ cases.
Some features of other `run` implementations that are intentionally excluded
are:

* Running `pre` or `post` scripts in addition to the specified script.
* Running `post` scripts in addition to the specified script. Running the
matching `pre` script is available as an opt-in via [`--enable-run-hooks`][].
* Defining package manager-specific environment variables.

#### Environment variables
Expand Down Expand Up @@ -4429,6 +4443,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--cpu-prof-dir`]: #--cpu-prof-dir
[`--diagnostic-dir`]: #--diagnostic-dirdirectory
[`--disable-sigusr1`]: #--disable-sigusr1
[`--enable-run-hooks`]: #--enable-run-hooks
[`--env-file-if-exists`]: #--env-file-if-existsfile
[`--env-file`]: #--env-filefile
[`--experimental-sea-config`]: single-executable-applications.md#1-generating-single-executable-preparation-blobs
Expand All @@ -4441,6 +4456,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--print`]: #-p---print-script
[`--redirect-warnings`]: #--redirect-warningsfile
[`--require`]: #-r---require-module
[`--run`]: #--run
[`--use-env-proxy`]: #--use-env-proxy
[`--use-system-ca`]: #--use-system-ca
[`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
Expand Down
6 changes: 6 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,12 @@ priority than \fB--dns-result-order\fR.
Enable FIPS-compliant crypto at startup. (Requires Node.js to be built
against FIPS-compatible OpenSSL.)
.
.It Fl -enable-run-hooks
When used together with \fB--run\fR, also run the matching \fB"pre<script>"\fR
lifecycle hook (if one exists in the \fB"scripts"\fR object) before the
requested script. The hook must exit successfully for the script to run, and
positional arguments are forwarded to the requested script only, not the hook.
.
.It Fl -enable-source-maps
Enable Source Map support for stack traces.
When using a transpiler, such as TypeScript, stack traces thrown by an
Expand Down
6 changes: 4 additions & 2 deletions src/node.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1131,8 +1131,10 @@ InitializeOncePerProcessInternal(const std::vector<std::string>& args,
if (!per_process::cli_options->run.empty()) {
auto positional_args = task_runner::GetPositionalArgs(args);
result->early_return_ = true;
task_runner::RunTask(
result, per_process::cli_options->run, positional_args);
task_runner::RunTask(result,
per_process::cli_options->run,
positional_args,
per_process::cli_options->enable_run_hooks);
return result;
}

Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,10 @@ PerProcessOptionsParser::PerProcessOptionsParser(
AddOption("--run",
"Run a script specified in package.json",
&PerProcessOptions::run);
AddOption("--enable-run-hooks",
"When used with --run, also run the matching \"pre<script>\" "
"lifecycle hook (if any) before the script",
&PerProcessOptions::enable_run_hooks);
AddOption(
"--disable-wasm-trap-handler",
"Disable trap-handler-based WebAssembly bound checks. V8 will insert "
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ class PerProcessOptions : public Options {
bool print_version = false;
std::string experimental_sea_config;
std::string run;
bool enable_run_hooks = false;

std::string build_sea;
#ifdef NODE_HAVE_I18N_SUPPORT
Expand Down
31 changes: 30 additions & 1 deletion src/node_task_runner.cc
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,8 @@ FindPackageJson(const std::filesystem::path& cwd) {

void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
std::string_view command_id,
const std::vector<std::string_view>& positional_args) {
const std::vector<std::string_view>& positional_args,
bool run_hooks) {
auto cwd = std::filesystem::current_path();
auto package_json = FindPackageJson(cwd);

Expand Down Expand Up @@ -300,6 +301,21 @@ void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
return;
}

// When lifecycle hooks are enabled, resolve the matching "pre<script>" hook
// up front. We copy the command into an owned string and rewind the scripts
// object so the main script lookup below is unaffected. A missing hook, or a
// hook whose value is not a string, is simply ignored.
std::string pre_command;
std::string pre_script_name;
if (run_hooks) {
pre_script_name = "pre" + std::string(command_id);
std::string_view pre_view;
if (!scripts_object[pre_script_name].get_string().get(pre_view)) {
pre_command = std::string(pre_view);
}
scripts_object.reset();
}

// If the command_id is not found in the scripts object, throw an error.
std::string_view command;
if (auto command_error =
Expand Down Expand Up @@ -339,6 +355,19 @@ void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
return;
}

// Run the "pre<script>" lifecycle hook first when enabled and present.
// Mirroring npm, lifecycle hooks do not receive the positional arguments
// (those belong to the main script only), and a failing hook aborts the run
// before the main script is executed.
if (run_hooks && !pre_command.empty()) {
auto pre_runner = ProcessRunner(
result, path, pre_script_name, pre_command, path_env_var, {});
pre_runner.Run();
if (result->exit_code_ != ExitCode::kNoFailure) {
return;
}
}

auto runner = ProcessRunner(
result, path, command_id, command, path_env_var, positional_args);
runner.Run();
Expand Down
3 changes: 2 additions & 1 deletion src/node_task_runner.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ FindPackageJson(const std::filesystem::path& cwd);

void RunTask(const std::shared_ptr<InitializationResultImpl>& result,
std::string_view command_id,
const PositionalArgs& positional_args);
const PositionalArgs& positional_args,
bool run_hooks = false);
PositionalArgs GetPositionalArgs(const std::vector<std::string>& args);
std::string EscapeShell(std::string_view command);

Expand Down
17 changes: 17 additions & 0 deletions test/fixtures/run-script/hooks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"scripts": {
"prebuild": "echo prebuild-output",
"build": "echo build-output",

"prefail-build": "echo prefail-output && exit 1",
"fail-build": "echo fail-build-main-output",

"prewith-args": "echo prehook-no-args",
"with-args": "echo main-output",

"no-hook": "echo no-hook-main-output",

"prebad-type": 123,
"bad-type": "echo bad-type-main-output"
}
}
102 changes: 102 additions & 0 deletions test/parallel/test-node-run-hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

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

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

const fixtures = require('../common/fixtures');
const hooksDir = fixtures.path('run-script/hooks');

describe('node --run [command] with lifecycle hooks', () => {
it('runs the "pre<script>" hook before the script with --enable-run-hooks', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'build', '--enable-run-hooks'],
{ cwd: hooksDir },
);
assert.strictEqual(child.code, 0);
assert.strictEqual(child.stderr, '');
assert.match(child.stdout, /prebuild-output/);
assert.match(child.stdout, /build-output/);
// The hook must run strictly before the main script.
assert.ok(
child.stdout.indexOf('prebuild-output') <
child.stdout.indexOf('build-output'),
`expected prebuild-output before build-output, got: ${child.stdout}`,
);
});

it('does not run the "pre<script>" hook without the flag', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'build'],
{ cwd: hooksDir },
);
assert.strictEqual(child.code, 0);
assert.strictEqual(child.stderr, '');
assert.match(child.stdout, /build-output/);
assert.doesNotMatch(child.stdout, /prebuild-output/);
});

it('aborts and does not run the script when the hook fails', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'fail-build', '--enable-run-hooks'],
{ cwd: hooksDir },
);
// The failing hook exits with status 1 and the main script never runs.
assert.strictEqual(child.code, 1);
assert.match(child.stdout, /prefail-output/);
assert.doesNotMatch(child.stdout, /fail-build-main-output/);
});

it('runs the failing script normally when hooks are disabled', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'fail-build'],
{ cwd: hooksDir },
);
// Without the flag the "prefail-build" hook is ignored entirely.
assert.strictEqual(child.code, 0);
assert.match(child.stdout, /fail-build-main-output/);
assert.doesNotMatch(child.stdout, /prefail-output/);
});

it('passes positional arguments to the script only, not the hook', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'with-args', '--enable-run-hooks', '--', 'EXTRA1', 'EXTRA2'],
{ cwd: hooksDir },
);
assert.strictEqual(child.code, 0);
assert.strictEqual(child.stderr, '');
assert.match(child.stdout, /prehook-no-args/);
assert.match(child.stdout, /main-output EXTRA1 EXTRA2/);
// The hook must not receive the positional arguments.
assert.doesNotMatch(child.stdout, /prehook-no-args EXTRA1/);
});

it('runs the script when there is no matching hook', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'no-hook', '--enable-run-hooks'],
{ cwd: hooksDir },
);
assert.strictEqual(child.code, 0);
assert.strictEqual(child.stderr, '');
assert.match(child.stdout, /no-hook-main-output/);
});

it('ignores a hook whose value is not a string', async () => {
const child = await common.spawnPromisified(
process.execPath,
['--run', 'bad-type', '--enable-run-hooks'],
{ cwd: hooksDir },
);
assert.strictEqual(child.code, 0);
assert.strictEqual(child.stderr, '');
assert.match(child.stdout, /bad-type-main-output/);
});
});
Loading