Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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 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,17 @@ fn root_package_json_source(
Global::exit(1);
}

/// The parser turns an empty file into `{}`, and installing from `{}` deletes the lockfile.
/// Callers check the contents as they came from disk, before anything is printed over them.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
30 changes: 27 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,6 +5260,23 @@ 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,
});
});
}
}
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Loading