Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
ddcd9bd4b6 [autofix.ci] apply automated fixes 2025-11-22 13:30:11 +11:00
Claude
508b460197 Add support for dependencies lifecycle script (#24901)
Implements the 'dependencies' lifecycle script that runs when the dependency
tree is modified during bun install.

Behavior:
- Runs for root package when dependencies change (did_meta_hash_change)
- Runs for workspace packages when their dependencies change
- Does NOT run on subsequent installs when nothing changes

This improves on npm which only runs the dependencies script for the root
package and ignores workspace packages entirely. Bun's implementation is
consistent with how other lifecycle scripts like postinstall work.
2025-11-22 13:09:27 +11:00
6 changed files with 159 additions and 6 deletions

View File

@@ -7,6 +7,7 @@ Packages on `npm` can define _lifecycle scripts_ in their `package.json`. Some o
- `preinstall`: Runs before the package is installed
- `postinstall`: Runs after the package is installed
- `dependencies`: Runs after the dependency tree is modified (root and workspace packages only)
- `preuninstall`: Runs before the package is uninstalled
- `prepublishOnly`: Runs before the package is published

View File

@@ -208,7 +208,7 @@ pub fn reportSlowLifecycleScripts(this: *PackageManager) void {
}
}
pub fn loadRootLifecycleScripts(this: *PackageManager, root_package: Package) void {
pub fn loadRootLifecycleScripts(this: *PackageManager, root_package: Package, dependencies_changed: bool) void {
const binding_dot_gyp_path = Path.joinAbsStringZ(
Fs.FileSystem.instance.top_level_dir,
&[_]string{"binding.gyp"},
@@ -232,6 +232,7 @@ pub fn loadRootLifecycleScripts(this: *PackageManager, root_package: Package) vo
name,
.root,
add_node_gyp_rebuild_script,
dependencies_changed,
);
} else {
if (Syscall.exists(binding_dot_gyp_path)) {
@@ -243,6 +244,7 @@ pub fn loadRootLifecycleScripts(this: *PackageManager, root_package: Package) vo
name,
.root,
true,
dependencies_changed,
);
}
}

View File

@@ -659,7 +659,8 @@ pub fn installWithManager(
}
// append scripts to lockfile before generating new metahash
manager.loadRootLifecycleScripts(root);
// Load without dependencies script initially (we'll reload later with correct flag)
manager.loadRootLifecycleScripts(root, false);
defer {
if (manager.root_lifecycle_scripts) |root_scripts| {
manager.allocator.free(root_scripts.package_name);
@@ -680,6 +681,10 @@ pub fn installWithManager(
manager.lockfile.buffers.string_bytes.items,
.workspace,
false,
// Include dependencies script for workspace packages. Unlike npm (which
// doesn't run dependencies script for workspaces at all), we run it
// consistently with other lifecycle scripts like postinstall.
true,
);
if (comptime Environment.allow_assert) {
@@ -702,6 +707,8 @@ pub fn installWithManager(
manager.lockfile.buffers.string_bytes.items,
.workspace,
true,
// NOTE: See comment above about workspace dependencies script behavior
true,
);
if (comptime Environment.allow_assert) {
@@ -882,6 +889,14 @@ pub fn installWithManager(
}
if (manager.options.do.run_scripts and install_root_dependencies and !manager.options.global) {
// Reload root lifecycle scripts with correct dependencies_changed flag
// The dependencies script should only run if dependencies actually changed
if (manager.root_lifecycle_scripts) |old_scripts| {
manager.allocator.free(old_scripts.package_name);
manager.root_lifecycle_scripts = null;
}
manager.loadRootLifecycleScripts(root, did_meta_hash_change);
if (manager.root_lifecycle_scripts) |scripts| {
if (comptime Environment.allow_assert) {
bun.assert(scripts.total > 0);

View File

@@ -62,6 +62,7 @@ pub const Scripts = struct {
"preprepare",
"prepare",
"postprepare",
"dependencies",
};
const RunCommand = @import("../cli/run_command.zig").RunCommand;
@@ -72,6 +73,7 @@ pub const Scripts = struct {
preprepare: Entries = .{},
prepare: Entries = .{},
postprepare: Entries = .{},
dependencies: Entries = .{},
pub fn hasAny(this: *Scripts) bool {
inline for (Scripts.names) |hook| {

View File

@@ -5,6 +5,7 @@ pub const Scripts = extern struct {
preprepare: String = .{},
prepare: String = .{},
postprepare: String = .{},
dependencies: String = .{},
filled: bool = false,
pub fn eql(l: *const Package.Scripts, r: *const Package.Scripts, l_buf: string, r_buf: string) bool {
@@ -13,7 +14,8 @@ pub const Scripts = extern struct {
l.postinstall.eql(r.postinstall, l_buf, r_buf) and
l.preprepare.eql(r.preprepare, l_buf, r_buf) and
l.prepare.eql(r.prepare, l_buf, r_buf) and
l.postprepare.eql(r.postprepare, l_buf, r_buf);
l.postprepare.eql(r.postprepare, l_buf, r_buf) and
l.dependencies.eql(r.dependencies, l_buf, r_buf);
}
pub const List = struct {
@@ -114,12 +116,13 @@ pub const Scripts = extern struct {
lockfile_buf: string,
resolution_tag: Resolution.Tag,
add_node_gyp_rebuild_script: bool,
include_dependencies_script: bool,
// return: first_index, total, entries
) struct { i8, u8, [Lockfile.Scripts.names.len]?string } {
const allocator = lockfile.allocator;
var script_index: u8 = 0;
var first_script_index: i8 = -1;
var scripts: [6]?string = .{null} ** 6;
var scripts: [7]?string = .{null} ** 7;
var counter: u8 = 0;
if (add_node_gyp_rebuild_script) {
@@ -173,6 +176,16 @@ pub const Scripts = extern struct {
}
script_index += 1;
}
// dependencies script only runs for root packages and when dependencies changed
if (resolution_tag == .root) {
if (include_dependencies_script and !this.dependencies.isEmpty()) {
if (first_script_index == -1) first_script_index = @intCast(script_index);
scripts[script_index] = allocator.dupe(u8, this.dependencies.slice(lockfile_buf)) catch unreachable;
counter += 1;
}
script_index += 1;
}
},
.workspace => {
script_index += 1;
@@ -182,8 +195,19 @@ pub const Scripts = extern struct {
counter += 1;
}
script_index += 2;
// dependencies script also runs for root package in workspaces when dependencies changed
if (include_dependencies_script and !this.dependencies.isEmpty()) {
if (first_script_index == -1) first_script_index = @intCast(script_index);
scripts[script_index] = allocator.dupe(u8, this.dependencies.slice(lockfile_buf)) catch unreachable;
counter += 1;
}
script_index += 1;
},
else => {
// Skip dependencies script index for non-root packages
script_index += 1;
},
else => {},
}
return .{ first_script_index, counter, scripts };
@@ -197,9 +221,10 @@ pub const Scripts = extern struct {
package_name: string,
resolution_tag: Resolution.Tag,
add_node_gyp_rebuild_script: bool,
include_dependencies_script: bool,
) ?Package.Scripts.List {
const allocator = lockfile.allocator;
const first_index, const total, const scripts = getScriptEntries(this, lockfile, lockfile_buf, resolution_tag, add_node_gyp_rebuild_script);
const first_index, const total, const scripts = getScriptEntries(this, lockfile, lockfile_buf, resolution_tag, add_node_gyp_rebuild_script, include_dependencies_script);
if (first_index != -1) {
var cwd_buf: if (Environment.isWindows) bun.PathBuffer else void = undefined;
@@ -277,6 +302,7 @@ pub const Scripts = extern struct {
folder_name,
resolution.tag,
add_node_gyp_rebuild_script,
false, // dependencies script not for npm packages in node_modules
);
} else if (!this.filled) {
return this.createFromPackageJSON(
@@ -351,6 +377,7 @@ pub const Scripts = extern struct {
folder_name,
resolution_tag,
add_node_gyp_rebuild_script,
false, // dependencies script not for npm packages in node_modules
);
}
};

View File

@@ -0,0 +1,106 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// NOTE: The `dependencies` lifecycle script is an npm feature that runs after
// the dependency tree is modified. Bun's implementation improves on npm:
// - npm: only runs for root package, not for workspace packages
// - Bun: runs for both root and workspace packages (when dependencies change)
// This makes the dependencies script consistent with other lifecycle scripts like postinstall.
test("dependencies lifecycle script runs when dependencies change - issue #24901", async () => {
using dir = tempDir("issue-24901-install", {
"package.json": JSON.stringify({
name: "test-dependencies-script",
scripts: {
dependencies: "echo 'dependencies script ran' > output.txt",
},
dependencies: {
"is-odd": "3.0.1",
},
}),
});
// First install - should run the dependencies script
await using proc1 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, stderr1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
if (exitCode1 !== 0) {
console.error("Install failed:");
console.error("STDOUT:", stdout1);
console.error("STDERR:", stderr1);
}
expect(exitCode1).toBe(0);
// Check that the dependencies script ran
const outputFile = Bun.file(`${dir}/output.txt`);
const outputExists = await outputFile.exists();
expect(outputExists).toBe(true);
if (outputExists) {
const output = await outputFile.text();
expect(output.trim()).toBe("dependencies script ran");
}
});
test("dependencies lifecycle script does NOT run when dependencies don't change - issue #24901", async () => {
using dir = tempDir("issue-24901-no-change", {
"package.json": JSON.stringify({
name: "test-dependencies-script-no-change",
scripts: {
dependencies: "echo 'dependencies script ran' > output.txt",
},
dependencies: {
"is-odd": "3.0.1",
},
}),
});
// First install
await using proc1 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
await proc1.exited;
// Remove the output file
const outputPath = `${dir}/output.txt`;
try {
await Bun.$`rm ${outputPath}`.cwd(String(dir));
} catch {}
// Second install without changes - should NOT run the dependencies script
await using proc2 = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
if (exitCode2 !== 0) {
console.error("Second install failed:");
console.error("STDOUT:", stdout2);
console.error("STDERR:", stderr2);
}
expect(exitCode2).toBe(0);
// Check that the dependencies script did NOT run
const outputFile = Bun.file(outputPath);
const outputExists = await outputFile.exists();
expect(outputExists).toBe(false);
});