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
17 changes: 16 additions & 1 deletion src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1858,7 +1858,12 @@ fn root_package_json_source(
root_package_json_path.as_bytes(),
Default::default(),
) {
WorkspacePackageJsonCacheResult::Entry(entry) => return Ok(entry.source.clone()),
WorkspacePackageJsonCacheResult::Entry(entry) => {
if entry.source.contents.is_empty() {
exit_on_empty_package_json(root_package_json_path.as_bytes());
}
return Ok(entry.source.clone());
Comment thread
robobun marked this conversation as resolved.
}
WorkspacePackageJsonCacheResult::ReadErr(err) => ("read", err),
WorkspacePackageJsonCacheResult::ParseErr(err) => ("parse", err),
};
Expand All @@ -1875,6 +1880,16 @@ fn root_package_json_source(
Global::exit(1);
}

/// An empty file parses as `{}`, and installing from `{}` deletes the lockfile.
pub(crate) fn exit_on_empty_package_json(path: &[u8]) -> ! {
Output::err_generic(
"failed to parse '{}': file is empty",
(bstr::BStr::new(path),),
);
bun_core::note!("Restore package.json, or write {{}} to it to start without dependencies");
Global::exit(1)
}

#[cold]
#[inline(never)]
fn create_new_lockfile_and_enqueue(
Expand Down
8 changes: 8 additions & 0 deletions src/install/PackageManager/updatePackageJSONAndInstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,14 @@ fn update_package_json_and_install_with_manager_with_updates(
// is taken across this borrow; `PackageJSONEditor` and `do_patch_commit` touch only
// disjoint manager fields.
let current_package_json: &mut MapEntry = unsafe { &mut *current_package_json_ptr };
// `bun add` and `bun link` put their dependency into the empty file.
if current_package_json.source.contents.is_empty()
&& !matches!(subcommand, Subcommand::Add | Subcommand::Link)
{
install_with_manager::exit_on_empty_package_json(
manager.original_package_json_path.as_bytes(),
);
}
let mut current_package_json_root: bun_ast::Expr = current_package_json.root;
let current_package_json_indent = current_package_json.indentation;

Expand Down
73 changes: 70 additions & 3 deletions test/cli/install/bun-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5189,6 +5189,7 @@ describe.concurrent("bun-install", () => {
async function installWithBrokenRootPackageJson(
withLockfile: boolean,
breakPackageJson: (packageJsonPath: string) => Promise<void>,
command: string[] = ["install"],
) {
using dir = tempDir("broken-root-package-json", {
"package.json": JSON.stringify({ name: "foo", version: "0.0.1", dependencies: { dep: "file:./dep" } }),
Expand All @@ -5213,22 +5214,28 @@ describe.concurrent("bun-install", () => {
await breakPackageJson(join(String(dir), "package.json"));

await using proc = spawn({
cmd: [bunExe(), "install"],
cmd: [bunExe(), ...command],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toStartWith("bun install v1.");
return { stderr: normalizeBunSnapshot(stderr, String(dir)), exitCode };
expect(stdout).toStartWith(`bun ${command[0]} v1.`);
return {
stderr: normalizeBunSnapshot(stderr, String(dir)),
exitCode,
lockfileKept: await exists(join(String(dir), "bun.lock")),
};
}

const unparseable = (packageJsonPath: string) => writeFile(packageJsonPath, "foo");
const unreadable = async (packageJsonPath: string) => {
await rm(packageJsonPath);
await mkdir(packageJsonPath);
};
// What a writer that died between truncating and writing leaves behind.
const empty = (packageJsonPath: string) => writeFile(packageJsonPath, "");

for (const [lockfile, withLockfile] of [
["with a bun.lock", true],
Expand All @@ -5253,7 +5260,67 @@ describe.concurrent("bun-install", () => {
expect(stderr).toBe("EISDIR: failed to read '<dir>/package.json'");
expect(exitCode).toBe(1);
});

// An empty file parses as `{}` elsewhere. For the root that would mean "no
// dependencies", and the command would delete the lockfile. `bun update` reads
// the file on its own path before it installs, so it is checked as well.
for (const command of [["install"], ["update"], ["update", "dep"]]) {
it(`bun ${command.join(" ")} rejects an empty file and keeps the lockfile ${lockfile}`, async () => {
const result = await installWithBrokenRootPackageJson(withLockfile, empty, command);
expect(result).toEqual({
stderr: [
"error: failed to parse '<dir>/package.json': file is empty",
"note: Restore package.json, or write {} to it to start without dependencies",
].join("\n"),
exitCode: 1,
lockfileKept: withLockfile,
});
});
}
}

// A workspace member needs a name, so an empty member manifest already fails
// before anything is installed. Pinned here because it is the same kind of file.
it("an empty workspace member manifest fails on the missing name and keeps the lockfile", async () => {
using dir = tempDir("empty-workspace-member", {
"package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }),
"packages/foo/package.json": JSON.stringify({ name: "foo", dependencies: { dep: "file:../../dep" } }),
"dep/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }),
});
await using first = spawn({
cmd: [bunExe(), "install", "--lockfile-only"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [firstStdout, firstStderr, firstExitCode] = await Promise.all([
first.stdout.text(),
first.stderr.text(),
first.exited,
]);
expect(firstExitCode, `bun install --lockfile-only failed: ${firstStdout}${firstStderr}`).toBe(0);
const lockfileBefore = await file(join(String(dir), "bun.lock")).text();
expect(lockfileBefore).toContain('"dep"');

await writeFile(join(String(dir), "packages", "foo", "package.json"), "");
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toStartWith("bun install v1.");
expect(normalizeBunSnapshot(stderr, String(dir))).toBe(
['error: Missing "name" from package.json in packages/foo/package.json', " at <dir>/package.json"].join(
"\n",
),
);
expect(exitCode).toBe(1);
expect(await file(join(String(dir), "bun.lock")).text()).toBe(lockfileBefore);
});
});

test.serial("should report error on invalid format for dependencies", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Loading