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
40 changes: 40 additions & 0 deletions src/install/PackageManager/PackageManagerLifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use bun_semver::string::Builder as SemverStringBuilder;
use bun_sys as Syscall;

use crate::bun_fs::FileSystem;
use crate::bun_json;
use crate::initialize_store;

use super::directories;
use crate::lifecycle_script_runner::{
Expand Down Expand Up @@ -386,6 +388,8 @@ impl PackageManager {
path.append(original_path.as_slice())?;
script_env.put(b"PATH", path.slice())?;

put_npm_package_config_env(&mut script_env, cwd)?;

// Ownership transfers to `LifecycleScriptSubprocess`, which
// re-uses it across every `spawn_next_script` in the chain. Move the
// owning `NullDelimitedEnvMap` by value so its `K=V\0` buffers outlive
Expand Down Expand Up @@ -462,6 +466,42 @@ impl PackageManager {
}
}

/// The package's own `config` strings as `npm_package_config_<key>`, the subset `bun run` exports.
fn put_npm_package_config_env(
script_env: &mut bun_dotenv::Map,
package_dir: &[u8],
) -> Result<(), crate::Error> {
let package_json_path = join_abs_string_z::<platform::Auto>(package_dir, &[b"package.json"]);
let Ok(json_buf) = Syscall::File::read_from(Syscall::Fd::cwd(), package_json_path.as_bytes())
else {
return Ok(());
};
let json_src =
bun_ast::Source::init_path_string(package_json_path.as_bytes(), json_buf.as_slice());
let mut log = bun_ast::Log::init();

initialize_store();

let Ok(parsed) = bun_json::ParsedJson::parse_package_json(&json_src, &mut log) else {
return Ok(());
};
let Some(config) = parsed.root.get(b"config") else {
return Ok(());
};

config.try_for_each_property(|key, _key_loc, value| {
let Some(value) = value.as_utf8_string_literal() else {
return Ok(());
};
if key.is_empty() || value.is_empty() {
return Ok(());
}
script_env.put(&strings::concat(&[b"npm_package_config_", key]), value)
})?;

Ok(())
}

fn add_package_to_set(
set: &mut ArrayHashMap<PackageID, ()>,
lockfile: &Lockfile,
Expand Down
10 changes: 7 additions & 3 deletions src/install/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,9 +851,13 @@ impl RunCommand {
}
}

// DirInfo walk / npm_package_* seeding is performed by the T6 impl
// (`bun_runtime::cli::RunCommand::configure_env_for_run`); install
// callers discard the return value.
// Overwrite like npm: an outer `bun run` in another project may have left a stale value.
env_loader.map.put(
b"npm_config_local_prefix",
bun_fs::FileSystem::instance().top_level_dir(),
)?;

// `npm_package_*` is per package; see `spawn_package_lifecycle_scripts`.
Ok(core::ptr::null_mut())
}
}
Expand Down
90 changes: 90 additions & 0 deletions test/cli/install/bun-install-lifecycle-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,96 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) {
assertManifestsPopulated(join(packageDir, ".bun-cache"), verdaccio.registryUrl());
});

test("npm_config_local_prefix and npm_package_config_* are set for root and dependency scripts", async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;

// Each script dumps the variables under test into env.json in its own cwd.
const captureEnv = `
require("fs").writeFileSync(
"env.json",
JSON.stringify({
localPrefix: process.env.npm_config_local_prefix,
config: Object.fromEntries(
Object.entries(process.env).filter(([name]) => name.startsWith("npm_package_config_")),
),
}),
);
`;

await mkdir(join(packageDir, "dep"));
await Promise.all([
writeFile(
packageJson,
JSON.stringify({
name: "root-project",
version: "1.0.0",
config: {
port: "8080",
shared: "from-root",
// Like `bun run`, only non-empty top-level string values are exported.
retries: 3,
nested: { key: "value" },
blank: "",
},
scripts: { postinstall: `${bunExe()} capture-env.js` },
dependencies: { "my-dep": "file:./dep" },
trustedDependencies: ["my-dep"],
}),
),
writeFile(join(packageDir, "capture-env.js"), captureEnv),
writeFile(
join(packageDir, "dep", "package.json"),
JSON.stringify({
name: "my-dep",
version: "2.0.0",
config: { depkey: "dep-value", shared: "from-dep" },
scripts: { postinstall: `${bunExe()} capture-env.js` },
}),
),
writeFile(join(packageDir, "dep", "capture-env.js"), captureEnv),
]);

// `bun run`/`npm run` leave their own values in the environment of the commands they
// run (including this test under `bun run`); the install must replace them with its own.
const installEnv: Record<string, string> = Object.fromEntries(
Object.entries(testEnv).filter(([name]) => !name.startsWith("npm_package_config_")),
);
installEnv.npm_config_local_prefix = "/some/other/project";

const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "pipe",
stdin: "ignore",
stderr: "pipe",
env: installEnv,
});

const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(out).toContain("+ my-dep@dep");
expect(exitCode).toBe(0);

expect(await file(join(packageDir, "env.json")).json()).toEqual({
localPrefix: packageDir,
config: {
npm_package_config_port: "8080",
npm_package_config_shared: "from-root",
},
});

// A dependency sees its own `config`, not the root's.
expect(await file(join(packageDir, "node_modules", "my-dep", "env.json")).json()).toEqual({
localPrefix: packageDir,
config: {
npm_package_config_depkey: "dep-value",
npm_package_config_shared: "from-dep",
},
});
});

test("INIT_CWD is set to the correct directory", async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
Expand Down