From 3c4efb20a5287c719bc260e0f94f7acab1b1ecf0 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Mon, 7 Sep 2026 05:30:46 +0000
Subject: [PATCH 1/5] init: refuse to overwrite a package.json that does not
parse
`bun init` treated a package.json that failed to parse, or whose root
was not an object, the same as a missing one. It then created the file
again with O_TRUNC and wrote the blank scaffold over it, exit code 0,
with no mention of the parse error.
Print the parser error and exit 1 instead, like `bun add`,
`bun install` and `bun pm pkg` do. A read error on an existing
package.json exits 1 too. The file is left untouched.
---
src/runtime/cli/init_command.rs | 74 ++++++++++++++++-----------------
test/cli/init/init.test.ts | 34 +++++++++++++++
2 files changed, 71 insertions(+), 37 deletions(-)
diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs
index 8b23c6274da9..6b6221924ff5 100644
--- a/src/runtime/cli/init_command.rs
+++ b/src/runtime/cli/init_command.rs
@@ -440,12 +440,9 @@ impl InitCommand {
#[cfg(windows)]
let prev_file_pos = pkg.get_pos()?;
- if pkg
- .pread_all(package_json_contents.list.as_mut_slice(), 0)
- .is_err()
- {
- package_json_file = None;
- break 'read_package_json;
+ if let Err(err) = pkg.pread_all(package_json_contents.list.as_mut_slice(), 0) {
+ Output::err(err, "failed to read package.json", ());
+ Global::exit(1);
}
#[cfg(windows)]
pkg.seek_to(prev_file_pos)?;
@@ -466,45 +463,48 @@ impl InitCommand {
};
let mut did_load_package_json = false;
if !package_json_contents.list.is_empty() {
- 'process_package_json: {
- let source = bun_ast::Source::init_path_string(
- b"package.json",
- package_json_contents.list.as_slice(),
- );
- let mut log = bun_ast::Log::init();
- let package_json_expr: bun_ast::Expr =
- match json::parse_package_json_utf8(&source, &mut log, &bump) {
- Ok(e) => e,
- Err(_) => {
- package_json_file = None;
- break 'process_package_json;
+ // A package.json that exists but does not parse must never be
+ // treated as absent: that would overwrite it with the scaffold.
+ let source = bun_ast::Source::init_path_string(
+ b"package.json",
+ package_json_contents.list.as_slice(),
+ );
+ let mut log = bun_ast::Log::init();
+ let package_json_expr: bun_ast::Expr =
+ match json::parse_package_json_utf8(&source, &mut log, &bump) {
+ Ok(e) => e,
+ Err(err) => {
+ if log.errors > 0 {
+ let _ = log.print(std::ptr::from_mut(Output::error_writer()));
}
- };
+ Output::err(err, "failed to parse package.json", ());
+ Global::exit(1);
+ }
+ };
- if !package_json_expr.data.is_e_object() {
- package_json_file = None;
- break 'process_package_json;
- }
+ if !package_json_expr.data.is_e_object() {
+ Output::err_generic("package.json root must be an object", ());
+ Global::exit(1);
+ }
- fields.object = package_json_expr.data.e_object();
+ fields.object = package_json_expr.data.e_object();
- if let Some(name) = package_json_expr.get(b"name") {
- if let Some(str) = name.as_utf8_string_literal() {
- fields.name = str.to_vec();
- }
+ if let Some(name) = package_json_expr.get(b"name") {
+ if let Some(str) = name.as_utf8_string_literal() {
+ fields.name = str.to_vec();
}
+ }
- if let Some(name) = package_json_expr
- .get(b"module")
- .or_else(|| package_json_expr.get(b"main"))
- {
- if let Some(str_) = name.as_utf8_string_literal() {
- fields.entry_point = str_.to_vec();
- }
+ if let Some(name) = package_json_expr
+ .get(b"module")
+ .or_else(|| package_json_expr.get(b"main"))
+ {
+ if let Some(str_) = name.as_utf8_string_literal() {
+ fields.entry_point = str_.to_vec();
}
-
- did_load_package_json = true;
}
+
+ did_load_package_json = true;
}
if fields.entry_point.is_empty() && !minimal {
diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts
index 737c955cee53..4fb5e53ce059 100644
--- a/test/cli/init/init.test.ts
+++ b/test/cli/init/init.test.ts
@@ -311,6 +311,40 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
});
}, 30_000);
+ // A package.json that exists but cannot be loaded is an error, like it is
+ // for `bun add` / `bun pm pkg`. It used to be treated as "no package.json"
+ // and silently overwritten with the blank scaffold.
+ test.each([
+ {
+ name: "syntax error",
+ contents: '{\n "name": "myapp",\n "version": "2.0.0",\n "dependencies": {\n "foo": "^1.0.0",\n }\n',
+ error: 'error: Expected "}" but found end of file',
+ },
+ {
+ name: "non-object root",
+ contents: '["name", "myapp"]\n',
+ error: "error: package.json root must be an object",
+ },
+ ])("bun init -y errors on an invalid package.json instead of overwriting it ($name)", async ({ contents, error }) => {
+ await using temp = tempDir("bun-init-invalid-package-json", {
+ "package.json": contents,
+ });
+
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "init", "-y"],
+ cwd: temp,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: initEnv,
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+
+ expect(stdout).toBe("");
+ expect(stderr).toContain(error);
+ expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(contents);
+ expect(readdirSync(temp).sort()).toEqual(["package.json"]);
+ expect(exitCode).toBe(1);
+ });
+
test("bun init --react works", async () => {
await using temp = tempDir("bun-init--react-works", {});
From 494e20415447c5623c8c1db9654a33f6c95093a5 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Thu, 1 Oct 2026 21:26:54 +0000
Subject: [PATCH 2/5] init: carry the package.json state as Absent, Empty or
Loaded
Only ENOENT from the probe means that there is no package.json. Every
other outcome prints the absolute path and the cause and exits 1 before
the template picker: another open error, a stat or read error, a file
that is not a regular file, a parse error, a root that is not an object.
The write step has no truncating open. An absent file is created with
O_EXCL. If that fails with EEXIST, or the file was empty at the probe,
the step writes only into a regular file that is still empty.
A React template chosen by a flag returns before the probe. Its writer
never used the result and skips an existing package.json.
---
docs/runtime/templating/init.mdx | 2 +-
src/runtime/cli/init_command.rs | 309 ++++++++++++++++++++++---------
test/cli/init/init.test.ts | 280 ++++++++++++++++++++++++++--
3 files changed, 486 insertions(+), 105 deletions(-)
diff --git a/docs/runtime/templating/init.mdx b/docs/runtime/templating/init.mdx
index f50c20d6aca6..52875d288892 100644
--- a/docs/runtime/templating/init.mdx
+++ b/docs/runtime/templating/init.mdx
@@ -31,7 +31,7 @@ Press `enter` to accept the default answer for each prompt, or pass the `-y` fla
---
-`bun init` infers settings with sane defaults and is non-destructive when run multiple times.
+`bun init` infers settings with sane defaults and is non-destructive when run multiple times. If a `package.json` exists but cannot be read or parsed, `bun init` exits with an error and leaves the file unchanged.

diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs
index 6b6221924ff5..defdffc1efc7 100644
--- a/src/runtime/cli/init_command.rs
+++ b/src/runtime/cli/init_command.rs
@@ -393,61 +393,25 @@ impl InitCommand {
}
let _ = Fs::FileSystem::init(None)?;
+
+ // A React template picked by a flag leaves package.json to its writer,
+ // which creates each file exclusively and skips one that exists.
+ if template.is_react() {
+ template.write_files_and_run_bun_dev()?;
+ return Ok(());
+ }
+
let pathname =
Fs::PathName::init(Fs::FileSystem::get().top_level_dir_without_trailing_slash());
let destination_dir = Fd::cwd();
let mut fields = PackageJSONFields::default();
- let mut package_json_file: Option =
- bun_sys::File::openat(destination_dir, b"package.json", bun_sys::O::RDWR, 0).ok();
- let mut package_json_contents: MutableString = MutableString::init_empty();
+ // Outlives the probe: the parsed object borrows strings from it.
+ let mut package_json_contents: MutableString;
bun_ast::initialize_store();
// Arena for JSON parse / Expr building.
let bump = bun_alloc::Arena::new();
- 'read_package_json: {
- if let Some(pkg) = package_json_file.as_ref() {
- let size: u64 = 'brk: {
- #[cfg(windows)]
- {
- let Ok(end) = pkg.get_end_pos() else {
- break 'read_package_json;
- };
- if end == 0 {
- break 'read_package_json;
- }
- break 'brk end as u64;
- }
- #[cfg(not(windows))]
- {
- let Ok(stat) = pkg.stat() else {
- break 'read_package_json;
- };
- if bun_core::kind_from_mode(stat.st_mode as _) != bun_sys::FileKind::File
- || stat.st_size == 0
- {
- break 'read_package_json;
- }
- break 'brk stat.st_size as u64;
- }
- };
-
- package_json_contents =
- MutableString::init(usize::try_from(size).expect("int cast"))?;
- package_json_contents
- .list
- .resize(usize::try_from(size).expect("int cast"), 0);
-
- #[cfg(windows)]
- let prev_file_pos = pkg.get_pos()?;
- if let Err(err) = pkg.pread_all(package_json_contents.list.as_mut_slice(), 0) {
- Output::err(err, "failed to read package.json", ());
- Global::exit(1);
- }
- #[cfg(windows)]
- pkg.seek_to(prev_file_pos)?;
- }
- }
fields.name = 'brk: {
if let Ok(name) = Self::normalize_package_name(if !pathname.filename.is_empty() {
@@ -461,12 +425,55 @@ impl InitCommand {
}
b"project".to_vec()
};
- let mut did_load_package_json = false;
- if !package_json_contents.list.is_empty() {
- // A package.json that exists but does not parse must never be
- // treated as absent: that would overwrite it with the scaffold.
- let source = bun_ast::Source::init_path_string(
+ let existing_package_json = 'probe: {
+ let pkg = match bun_sys::File::openat(
+ destination_dir,
b"package.json",
+ bun_sys::O::RDWR,
+ 0,
+ ) {
+ Ok(file) => file,
+ Err(err) if err.get_errno() == bun_sys::E::ENOENT => {
+ break 'probe ExistingPackageJson::Absent;
+ }
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Open(err)),
+ };
+ let stat = match pkg.stat() {
+ Ok(stat) => stat,
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Stat(err)),
+ };
+ if bun_core::kind_from_mode(stat.st_mode as _) != bun_sys::FileKind::File {
+ exit_unusable_package_json(UnusablePackageJson::NotRegularFile);
+ }
+ if stat.st_size == 0 {
+ break 'probe ExistingPackageJson::Empty(pkg);
+ }
+
+ let size = usize::try_from(stat.st_size).expect("int cast");
+ package_json_contents = MutableString::init(size)?;
+ package_json_contents.list.resize(size, 0);
+
+ #[cfg(windows)]
+ let prev_file_pos = match pkg.get_pos() {
+ Ok(pos) => pos,
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Read(err)),
+ };
+ let read = match pkg.pread_all(package_json_contents.list.as_mut_slice(), 0) {
+ Ok(read) => read,
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Read(err)),
+ };
+ #[cfg(windows)]
+ if let Err(err) = pkg.seek_to(prev_file_pos) {
+ exit_unusable_package_json(UnusablePackageJson::Read(err));
+ }
+ package_json_contents.list.truncate(read);
+ if read == 0 {
+ break 'probe ExistingPackageJson::Empty(pkg);
+ }
+
+ let mut path_buf = path_buffer_pool::get();
+ let source = bun_ast::Source::init_path_string(
+ package_json_path(&mut path_buf),
package_json_contents.list.as_slice(),
);
let mut log = bun_ast::Log::init();
@@ -474,17 +481,13 @@ impl InitCommand {
match json::parse_package_json_utf8(&source, &mut log, &bump) {
Ok(e) => e,
Err(err) => {
- if log.errors > 0 {
- let _ = log.print(std::ptr::from_mut(Output::error_writer()));
- }
- Output::err(err, "failed to parse package.json", ());
- Global::exit(1);
+ let _ = log.print(std::ptr::from_mut(Output::error_writer()));
+ exit_unusable_package_json(UnusablePackageJson::Parse(err));
}
};
if !package_json_expr.data.is_e_object() {
- Output::err_generic("package.json root must be an object", ());
- Global::exit(1);
+ exit_unusable_package_json(UnusablePackageJson::RootNotObject);
}
fields.object = package_json_expr.data.e_object();
@@ -504,8 +507,8 @@ impl InitCommand {
}
}
- did_load_package_json = true;
- }
+ ExistingPackageJson::Loaded(pkg)
+ };
if fields.entry_point.is_empty() && !minimal {
'infer: {
@@ -560,14 +563,14 @@ impl InitCommand {
}
}
- if !did_load_package_json {
+ if !matches!(existing_package_json, ExistingPackageJson::Loaded(_)) {
fields.object = bun_ast::Expr::init(bun_ast::E::Object::default(), bun_ast::Loc::EMPTY)
.data
.e_object();
}
if !auto_yes {
- if !did_load_package_json {
+ if !matches!(existing_package_json, ExistingPackageJson::Loaded(_)) {
bun_core::pretty!("\n");
let selected = Self::radio::(b"Select a project template")?;
@@ -781,17 +784,51 @@ impl InitCommand {
template.write_to_package_json(&mut fields, &bump)?;
}
- 'write_package_json: {
- let (fd, created_close): (Fd, Option) = match package_json_file
- .as_ref()
- {
- Some(f) => (f.handle(), None),
- None => {
- let fd = bun_sys::File::create(Fd::cwd(), b"package.json", true)?.into_raw();
- (fd, Some(bun_sys::CloseOnDrop::new(fd)))
+ let wrote_package_json = 'write_package_json: {
+ let created: bun_sys::File;
+ let (fd, truncate_to_written): (Fd, bool) = match &existing_package_json {
+ ExistingPackageJson::Loaded(file) => (file.handle(), true),
+ ExistingPackageJson::Empty(file) => {
+ exit_unless_empty_regular_file(file);
+ (file.handle(), true)
+ }
+ ExistingPackageJson::Absent => {
+ match bun_sys::File::openat(
+ Fd::cwd(),
+ b"package.json",
+ bun_sys::O::WRONLY
+ | bun_sys::O::CREAT
+ | bun_sys::O::EXCL
+ | bun_sys::O::CLOEXEC,
+ 0o666,
+ ) {
+ Ok(file) => {
+ created = file;
+ (created.handle(), false)
+ }
+ // A dangling symlink (ENOENT at the probe), or a file
+ // created since the probe.
+ Err(err) if err.get_errno() == bun_sys::E::EEXIST => {
+ match bun_sys::File::openat(
+ Fd::cwd(),
+ b"package.json",
+ bun_sys::O::RDWR | bun_sys::O::CREAT | bun_sys::O::CLOEXEC,
+ 0o666,
+ ) {
+ Ok(file) => {
+ exit_unless_empty_regular_file(&file);
+ created = file;
+ (created.handle(), true)
+ }
+ Err(err) => {
+ exit_unusable_package_json(UnusablePackageJson::Open(err))
+ }
+ }
+ }
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Create(err)),
+ }
}
};
- let _close = created_close;
let mut buffer_writer = js_printer::BufferWriter::init();
buffer_writer.append_newline = true;
let mut package_json_writer = js_printer::BufferPrinter::init(buffer_writer);
@@ -814,8 +851,7 @@ impl InitCommand {
"package.json failed to write due to error {}",
err.name(),
);
- package_json_file = None;
- break 'write_package_json;
+ break 'write_package_json false;
}
let written = package_json_writer.ctx.get_written();
if let Err(err) = bun_sys::File::borrow(&fd).write_all(written) {
@@ -823,20 +859,21 @@ impl InitCommand {
"package.json failed to write due to error {}",
bstr::BStr::new(err.name()),
);
- package_json_file = None;
- break 'write_package_json;
+ break 'write_package_json false;
}
- if let Err(err) =
- bun_sys::ftruncate(fd, i64::try_from(written.len()).expect("int cast"))
- {
- bun_core::pretty_errorln!(
- "package.json failed to write due to error {}",
- bstr::BStr::new(err.name()),
- );
- package_json_file = None;
- break 'write_package_json;
+ if truncate_to_written {
+ if let Err(err) =
+ bun_sys::ftruncate(fd, i64::try_from(written.len()).expect("int cast"))
+ {
+ bun_core::pretty_errorln!(
+ "package.json failed to write due to error {}",
+ bstr::BStr::new(err.name()),
+ );
+ break 'write_package_json false;
+ }
}
- }
+ true
+ };
if steps.write_gitignore {
let _ = Assets::create(b".gitignore", Assets::GITIGNORE, &[]);
@@ -849,7 +886,9 @@ impl InitCommand {
Template::create_agent_rule();
}
- if package_json_file.is_some() && !did_load_package_json {
+ if wrote_package_json
+ && matches!(existing_package_json, ExistingPackageJson::Empty(_))
+ {
bun_core::prettyln!(" + package.json");
Output::flush();
}
@@ -910,7 +949,9 @@ impl InitCommand {
// suppressed
}
- if !fields.entry_point.is_empty() && !did_load_package_json {
+ if !fields.entry_point.is_empty()
+ && !matches!(existing_package_json, ExistingPackageJson::Loaded(_))
+ {
bun_core::pretty!("\nTo get started, run:\n\n ");
if strings::index_of_any(&fields.entry_point, b" \"'").is_some() {
@@ -1098,6 +1139,100 @@ impl Default for PackageJSONFields {
}
}
+// ──────────────────────────────────────────────────────────────────────────
+// Existing package.json
+// ──────────────────────────────────────────────────────────────────────────
+
+/// What `bun init` found at `package.json` in the destination directory. A
+/// file that exists but was not loaded has no variant: `exec` leaves through
+/// `exit_unusable_package_json`, so nothing can write over it.
+enum ExistingPackageJson {
+ /// The open failed with ENOENT.
+ Absent,
+ /// A regular file with no content, filled in through this handle.
+ Empty(bun_sys::File),
+ /// Read through this handle and parsed to an object.
+ Loaded(bun_sys::File),
+}
+
+/// Why `bun init` cannot use the package.json in the destination directory.
+enum UnusablePackageJson {
+ Open(bun_sys::Error),
+ Stat(bun_sys::Error),
+ NotRegularFile,
+ Read(bun_sys::Error),
+ Parse(bun_parsers::Error),
+ RootNotObject,
+ /// Absent or empty at the probe, but not an empty regular file at the write.
+ Changed,
+ Create(bun_sys::Error),
+}
+
+/// Absolute path of the destination directory's package.json.
+fn package_json_path(buf: &mut bun_paths::PathBuffer) -> &[u8] {
+ bun_paths::resolve_path::join_abs_string_buf::(
+ Fs::FileSystem::get().top_level_dir,
+ &mut buf[..],
+ &[b"package.json"],
+ )
+}
+
+#[cold]
+#[inline(never)]
+fn exit_unusable_package_json(cause: UnusablePackageJson) -> ! {
+ let mut path_buf = path_buffer_pool::get();
+ let path = bstr::BStr::new(package_json_path(&mut path_buf));
+ match cause {
+ UnusablePackageJson::Open(err)
+ if matches!(err.get_errno(), bun_sys::E::EACCES | bun_sys::E::EPERM) =>
+ {
+ Output::err(
+ err.name(),
+ "Permission denied while opening \"{s}\"",
+ &[&path],
+ );
+ bun_core::note!("package.json must be readable and writable for bun init to update it");
+ }
+ UnusablePackageJson::Open(err) => Output::err(&err, "could not open \"{s}\"", &[&path]),
+ UnusablePackageJson::Stat(err) => Output::err(&err, "could not stat \"{s}\"", &[&path]),
+ UnusablePackageJson::NotRegularFile => {
+ Output::err_generic("\"{s}\" is not a regular file", &[&path]);
+ }
+ UnusablePackageJson::Read(err) => Output::err(&err, "could not read \"{s}\"", &[&path]),
+ UnusablePackageJson::Parse(err) => {
+ Output::err(err, "failed to parse \"{s}\"", &[&path]);
+ bun_core::note!("fix or remove this file, then run 'bun init' again");
+ }
+ UnusablePackageJson::RootNotObject => {
+ Output::err_generic("package.json root must be an object in \"{s}\"", &[&path]);
+ bun_core::note!("fix or remove this file, then run 'bun init' again");
+ }
+ UnusablePackageJson::Changed => {
+ Output::err_generic(
+ "\"{s}\" appeared or changed while bun init was running",
+ &[&path],
+ );
+ bun_core::note!("run 'bun init' again");
+ }
+ UnusablePackageJson::Create(err) => {
+ Output::err(&err, "could not create \"{s}\"", &[&path]);
+ }
+ }
+ Global::exit(1);
+}
+
+/// `bun init` writes a package.json it did not load only into a regular file
+/// with no content.
+fn exit_unless_empty_regular_file(file: &bun_sys::File) {
+ match file.stat() {
+ Ok(stat)
+ if bun_core::kind_from_mode(stat.st_mode as _) == bun_sys::FileKind::File
+ && stat.st_size == 0 => {}
+ Ok(_) => exit_unusable_package_json(UnusablePackageJson::Changed),
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Stat(err)),
+ }
+}
+
// ──────────────────────────────────────────────────────────────────────────
// Radio choice enums
// ──────────────────────────────────────────────────────────────────────────
diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts
index 4fb5e53ce059..4ca9ac6edc62 100644
--- a/test/cli/init/init.test.ts
+++ b/test/cli/init/init.test.ts
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, test } from "bun:test";
import fs, { readdirSync } from "fs";
-import { bunEnv, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness";
+import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir, tempDirWithFiles } from "harness";
import path from "path";
// Whether `bun init` emits CLAUDE.md depends on a `claude` binary being on
@@ -311,40 +311,286 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
});
}, 30_000);
- // A package.json that exists but cannot be loaded is an error, like it is
- // for `bun add` / `bun pm pkg`. It used to be treated as "no package.json"
- // and silently overwritten with the blank scaffold.
+ // `bun init` fills in a package.json that is absent or empty, and merges into
+ // one that it parsed. Any other package.json is an error, like it is for
+ // `bun add` / `bun pm pkg`, and stays as it is. It used to be treated as "no
+ // package.json" and overwritten with the blank scaffold.
+ async function init(cwd: string, args: string[]) {
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "init", ...args],
+ cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ env: initEnv,
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ return { stdout, stderr: normalizeBunSnapshot(stderr, cwd), exitCode };
+ }
+ const fixOrRemove = "\nnote: fix or remove this file, then run 'bun init' again";
+ const rootNotAnObject = 'error: package.json root must be an object in "/package.json"';
+ // Root ignores permission bits, and Windows has none.
+ const permissionBitsApply = !isWindows && process.getuid!() !== 0;
+
test.each([
{
name: "syntax error",
contents: '{\n "name": "myapp",\n "version": "2.0.0",\n "dependencies": {\n "foo": "^1.0.0",\n }\n',
- error: 'error: Expected "}" but found end of file',
+ error:
+ '6 | }\n ^\nerror: Expected "}" but found end of file\n at /package.json:6:4\n' +
+ 'ParserError: failed to parse "/package.json"',
+ },
+ {
+ name: "lone {",
+ contents: "{",
+ error:
+ '1 | {\n ^\nerror: Expected "}" but found end of file\n at /package.json:1:1\n' +
+ 'ParserError: failed to parse "/package.json"',
+ },
+ {
+ name: "whitespace only",
+ contents: " \n",
+ error:
+ "error: Unexpected end of file\n at /package.json:1:3\n" +
+ 'ParserError: failed to parse "/package.json"',
+ },
+ {
+ name: "NUL byte in a string",
+ contents: '{ "name": "my\0app" }\n',
+ error:
+ '1 | { "name": "my\0app" }\n ^\nerror: Syntax Error\n at /package.json:1:11\n' +
+ 'SyntaxError: failed to parse "/package.json"',
},
{
- name: "non-object root",
- contents: '["name", "myapp"]\n',
- error: "error: package.json root must be an object",
+ name: "UTF-16",
+ contents: Buffer.from("\uFEFF{}", "utf16le"),
+ error:
+ "1 | \uFFFD\uFFFD{\0}\0\n ^\nerror: Unexpected \uFFFD\uFFFD\n at /package.json:1:1\n" +
+ 'ParserError: failed to parse "/package.json"',
},
+ { name: "non-object root", contents: '["name", "myapp"]\n', error: rootNotAnObject },
+ { name: "string root", contents: '"myapp"\n', error: rootNotAnObject },
+ { name: "number root", contents: "42\n", error: rootNotAnObject },
+ { name: "null root", contents: "null\n", error: rootNotAnObject },
])("bun init -y errors on an invalid package.json instead of overwriting it ($name)", async ({ contents, error }) => {
await using temp = tempDir("bun-init-invalid-package-json", {
"package.json": contents,
});
- await using proc = Bun.spawn({
- cmd: [bunExe(), "init", "-y"],
- cwd: temp,
- stdio: ["ignore", "pipe", "pipe"],
- env: initEnv,
- });
- const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ const { stdout, stderr, exitCode } = await init(temp, ["-y"]);
+
+ expect(stdout).toBe("");
+ expect(stderr).toBe(error + fixOrRemove);
+ expect(fs.readFileSync(path.join(temp, "package.json"))).toEqual(Buffer.from(contents));
+ expect(readdirSync(temp)).toEqual(["package.json"]);
+ expect(exitCode).toBe(1);
+ });
+
+ test.each([
+ { name: "-m -y", args: ["-m", "-y"], file: "package.json" },
+ { name: "stdin is not a TTY", args: [], file: "package.json" },
+ { name: "-y ", args: ["-y", "sub"], file: "sub/package.json" },
+ ])("bun init errors on an invalid package.json from every entry point ($name)", async ({ args, file }) => {
+ await using temp = tempDir("bun-init-invalid-package-json-entry", { [file]: "{" });
+
+ const { stdout, stderr, exitCode } = await init(temp, args);
+
+ expect(stdout).toBe("");
+ expect(stderr).toBe(
+ `1 | {\n ^\nerror: Expected "}" but found end of file\n at /${file}:1:1\n` +
+ `ParserError: failed to parse "/${file}"` +
+ fixOrRemove,
+ );
+ expect(await Bun.file(path.join(temp, file)).text()).toBe("{");
+ expect(readdirSync(path.dirname(path.join(temp, file)))).toEqual(["package.json"]);
+ expect(exitCode).toBe(1);
+ });
+
+ test("bun init -y errors when package.json is a directory", async () => {
+ await using temp = tempDir("bun-init-package-json-directory", { "package.json": {} });
+
+ const { stdout, stderr, exitCode } = await init(temp, ["-y"]);
expect(stdout).toBe("");
- expect(stderr).toContain(error);
+ // Windows opens a handle to the directory, so the file kind check reports it.
+ expect(stderr).toBe(
+ isWindows
+ ? 'error: "/package.json" is not a regular file'
+ : 'EISDIR: Is a directory: could not open "/package.json" (open)',
+ );
+ expect(readdirSync(temp)).toEqual(["package.json"]);
+ expect(readdirSync(path.join(temp, "package.json"))).toEqual([]);
+ expect(exitCode).toBe(1);
+ });
+
+ // Creating a symlink needs a privilege on Windows.
+ test.skipIf(isWindows).each([
+ {
+ name: "symlink loop",
+ target: "package.json",
+ error: 'ELOOP: Too many levels of symbolic links: could not open "/package.json" (open)',
+ },
+ { name: "symlink to a device", target: "/dev/null", error: 'error: "/package.json" is not a regular file' },
+ ])("bun init -y errors when package.json is not a regular file ($name)", async ({ target, error }) => {
+ await using temp = tempDir("bun-init-package-json-symlink", {});
+ fs.symlinkSync(target, path.join(temp, "package.json"));
+
+ const { stdout, stderr, exitCode } = await init(temp, ["-y"]);
+
+ expect(stdout).toBe("");
+ expect(stderr).toBe(error);
+ expect(fs.readlinkSync(path.join(temp, "package.json"))).toBe(target);
+ expect(readdirSync(temp)).toEqual(["package.json"]);
+ expect(exitCode).toBe(1);
+ });
+
+ test.skipIf(!permissionBitsApply).each([
+ { name: "write-only", mode: 0o200 },
+ { name: "read-only", mode: 0o444 },
+ ])("bun init -y errors on a package.json it may not read and write ($name)", async ({ mode }) => {
+ const contents = '{ "name": "myapp", "version": "2.0.0" }\n';
+ await using temp = tempDir("bun-init-package-json-mode", { "package.json": contents });
+ fs.chmodSync(path.join(temp, "package.json"), mode);
+
+ const { stdout, stderr, exitCode } = await init(temp, ["-y"]);
+
+ expect(stdout).toBe("");
+ expect(stderr).toBe(
+ 'EACCES: Permission denied while opening "/package.json"\n' +
+ "note: package.json must be readable and writable for bun init to update it",
+ );
+ fs.chmodSync(path.join(temp, "package.json"), 0o644);
expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(contents);
- expect(readdirSync(temp).sort()).toEqual(["package.json"]);
+ expect(readdirSync(temp)).toEqual(["package.json"]);
expect(exitCode).toBe(1);
});
+ test("bun init -y -m fills in an empty package.json", async () => {
+ await using temp = tempDir("bun-init-empty-package-json", { "package.json": "" });
+
+ const { stdout, exitCode } = await init(temp, ["-y", "-m"]);
+
+ expect(stdout.split("\n")[0]).toBe(" + package.json");
+ expect(await Bun.file(path.join(temp, "package.json")).json()).toEqual({
+ devDependencies: { "@types/bun": "latest" },
+ });
+ expect(exitCode).toBe(0);
+ }, 30_000);
+
+ // Creating a symlink needs a privilege on Windows.
+ test.skipIf(isWindows)(
+ "bun init -y -m creates the target of a dangling package.json symlink",
+ async () => {
+ await using temp = tempDir("bun-init-dangling-package-json", {});
+ fs.symlinkSync("target.json", path.join(temp, "package.json"));
+
+ const { exitCode } = await init(temp, ["-y", "-m"]);
+
+ expect(fs.readlinkSync(path.join(temp, "package.json"))).toBe("target.json");
+ expect(await Bun.file(path.join(temp, "target.json")).json()).toEqual({
+ devDependencies: { "@types/bun": "latest" },
+ });
+ expect(exitCode).toBe(0);
+ },
+ 30_000,
+ );
+
+ // The React templates never load package.json. Their writer skips one that exists.
+ test("bun init --react skips a package.json it cannot parse", async () => {
+ await using temp = tempDir("bun-init-react-invalid-package-json", { "package.json": "{" });
+
+ const { stdout, exitCode } = await init(temp, ["--react"]);
+
+ expect(stdout.split("\n")).toContain(" ○ package.json (already exists, skipping)");
+ expect(await Bun.file(path.join(temp, "package.json")).text()).toBe("{");
+ expect(fs.existsSync(path.join(temp, "bunfig.toml"))).toBe(true);
+ expect(exitCode).toBe(0);
+ }, 30_000);
+
+ test.skipIf(!permissionBitsApply)(
+ "bun init --react skips a read-only package.json",
+ async () => {
+ const contents = '{ "name": "myapp" }\n';
+ await using temp = tempDir("bun-init-react-read-only-package-json", { "package.json": contents });
+ fs.chmodSync(path.join(temp, "package.json"), 0o444);
+
+ const { stdout, exitCode } = await init(temp, ["--react"]);
+
+ expect(stdout.split("\n")).toContain(" ○ package.json (already exists, skipping)");
+ expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(contents);
+ expect(exitCode).toBe(0);
+ },
+ 30_000,
+ );
+
+ // ConPTY rewrites the child's output (it wraps lines at `cols`), so the text
+ // these tests read from the terminal is only stable on a POSIX pty.
+ describe.skipIf(isWindows)("on a TTY", () => {
+ /** Runs `bun init` on a pty. `onPicker` runs once the template picker is shown. */
+ async function initOnTTY(cwd: string, onPicker?: (terminal: Bun.Terminal) => void) {
+ const decoder = new TextDecoder();
+ let output = "";
+ const picker = Promise.withResolvers();
+ await using terminal = new Bun.Terminal({
+ cols: 80,
+ rows: 24,
+ data(_, chunk: Uint8Array) {
+ output += decoder.decode(chunk, { stream: true });
+ if (output.includes("Select a project template")) picker.resolve();
+ },
+ });
+ await using proc = Bun.spawn({ cmd: [bunExe(), "init"], cwd, env: initEnv, terminal });
+ if (onPicker) {
+ // Fail with the child's output if it exits before the picker.
+ const exitedEarly = proc.exited.then(code => {
+ throw new Error(`bun init exited before the template picker (code ${code}):\n${output}`);
+ });
+ exitedEarly.catch(() => {});
+ await Promise.race([picker.promise, exitedEarly]);
+ onPicker(terminal);
+ }
+ const exitCode = await proc.exited;
+ return { output: normalizeBunSnapshot(Bun.stripANSI(output), cwd), exitCode };
+ }
+
+ test("bun init errors on an invalid package.json before the template picker", async () => {
+ await using temp = tempDir("bun-init-tty-invalid-package-json", { "package.json": "{" });
+
+ const { output, exitCode } = await initOnTTY(temp);
+
+ expect(output).toBe(
+ '1 | {\n ^\nerror: Expected "}" but found end of file\n at /package.json:1:1\n' +
+ 'ParserError: failed to parse "/package.json"' +
+ fixOrRemove,
+ );
+ expect(await Bun.file(path.join(temp, "package.json")).text()).toBe("{");
+ expect(readdirSync(temp)).toEqual(["package.json"]);
+ expect(exitCode).toBe(1);
+ });
+
+ test.each([
+ { name: "created", before: undefined },
+ { name: "given content", before: "" },
+ ])("bun init keeps a package.json that is $name while the template picker is open", async ({ before }) => {
+ const saved = '{ "name": "saved-during-the-picker" }\n';
+ await using temp = tempDir(
+ "bun-init-tty-package-json-changed",
+ before === undefined ? {} : { "package.json": before },
+ );
+
+ const { output, exitCode } = await initOnTTY(temp, terminal => {
+ fs.writeFileSync(path.join(temp, "package.json"), saved);
+ // "1" picks the first entry (Blank) and submits it.
+ terminal.write("1");
+ });
+
+ expect(output).toEndWith(
+ 'error: "/package.json" appeared or changed while bun init was running\n' + "note: run 'bun init' again",
+ );
+ expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(saved);
+ expect(readdirSync(temp)).toEqual(["package.json"]);
+ expect(exitCode).toBe(1);
+ });
+ });
+
test("bun init --react works", async () => {
await using temp = tempDir("bun-init--react-works", {});
From 99e873272d9cf58cceb50778af137221bf646ff1 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Fri, 2 Oct 2026 02:17:28 +0000
Subject: [PATCH 3/5] init: shorten comments, move the package.json helpers,
drop test timeouts
Move the package.json state types and their helpers into the existing
Helpers section. Keep each new comment to one line. Remove the explicit
timeouts from the four new tests.
---
src/runtime/cli/init_command.rs | 186 +++++++++++++++-----------------
test/cli/init/init.test.ts | 50 ++++-----
2 files changed, 109 insertions(+), 127 deletions(-)
diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs
index defdffc1efc7..d18f9554baa5 100644
--- a/src/runtime/cli/init_command.rs
+++ b/src/runtime/cli/init_command.rs
@@ -394,8 +394,7 @@ impl InitCommand {
let _ = Fs::FileSystem::init(None)?;
- // A React template picked by a flag leaves package.json to its writer,
- // which creates each file exclusively and skips one that exists.
+ // The React writer never loads package.json. It skips one that exists.
if template.is_react() {
template.write_files_and_run_bun_dev()?;
return Ok(());
@@ -806,8 +805,7 @@ impl InitCommand {
created = file;
(created.handle(), false)
}
- // A dangling symlink (ENOENT at the probe), or a file
- // created since the probe.
+ // A dangling symlink, or a file created since the probe.
Err(err) if err.get_errno() == bun_sys::E::EEXIST => {
match bun_sys::File::openat(
Fd::cwd(),
@@ -1139,100 +1137,6 @@ impl Default for PackageJSONFields {
}
}
-// ──────────────────────────────────────────────────────────────────────────
-// Existing package.json
-// ──────────────────────────────────────────────────────────────────────────
-
-/// What `bun init` found at `package.json` in the destination directory. A
-/// file that exists but was not loaded has no variant: `exec` leaves through
-/// `exit_unusable_package_json`, so nothing can write over it.
-enum ExistingPackageJson {
- /// The open failed with ENOENT.
- Absent,
- /// A regular file with no content, filled in through this handle.
- Empty(bun_sys::File),
- /// Read through this handle and parsed to an object.
- Loaded(bun_sys::File),
-}
-
-/// Why `bun init` cannot use the package.json in the destination directory.
-enum UnusablePackageJson {
- Open(bun_sys::Error),
- Stat(bun_sys::Error),
- NotRegularFile,
- Read(bun_sys::Error),
- Parse(bun_parsers::Error),
- RootNotObject,
- /// Absent or empty at the probe, but not an empty regular file at the write.
- Changed,
- Create(bun_sys::Error),
-}
-
-/// Absolute path of the destination directory's package.json.
-fn package_json_path(buf: &mut bun_paths::PathBuffer) -> &[u8] {
- bun_paths::resolve_path::join_abs_string_buf::(
- Fs::FileSystem::get().top_level_dir,
- &mut buf[..],
- &[b"package.json"],
- )
-}
-
-#[cold]
-#[inline(never)]
-fn exit_unusable_package_json(cause: UnusablePackageJson) -> ! {
- let mut path_buf = path_buffer_pool::get();
- let path = bstr::BStr::new(package_json_path(&mut path_buf));
- match cause {
- UnusablePackageJson::Open(err)
- if matches!(err.get_errno(), bun_sys::E::EACCES | bun_sys::E::EPERM) =>
- {
- Output::err(
- err.name(),
- "Permission denied while opening \"{s}\"",
- &[&path],
- );
- bun_core::note!("package.json must be readable and writable for bun init to update it");
- }
- UnusablePackageJson::Open(err) => Output::err(&err, "could not open \"{s}\"", &[&path]),
- UnusablePackageJson::Stat(err) => Output::err(&err, "could not stat \"{s}\"", &[&path]),
- UnusablePackageJson::NotRegularFile => {
- Output::err_generic("\"{s}\" is not a regular file", &[&path]);
- }
- UnusablePackageJson::Read(err) => Output::err(&err, "could not read \"{s}\"", &[&path]),
- UnusablePackageJson::Parse(err) => {
- Output::err(err, "failed to parse \"{s}\"", &[&path]);
- bun_core::note!("fix or remove this file, then run 'bun init' again");
- }
- UnusablePackageJson::RootNotObject => {
- Output::err_generic("package.json root must be an object in \"{s}\"", &[&path]);
- bun_core::note!("fix or remove this file, then run 'bun init' again");
- }
- UnusablePackageJson::Changed => {
- Output::err_generic(
- "\"{s}\" appeared or changed while bun init was running",
- &[&path],
- );
- bun_core::note!("run 'bun init' again");
- }
- UnusablePackageJson::Create(err) => {
- Output::err(&err, "could not create \"{s}\"", &[&path]);
- }
- }
- Global::exit(1);
-}
-
-/// `bun init` writes a package.json it did not load only into a regular file
-/// with no content.
-fn exit_unless_empty_regular_file(file: &bun_sys::File) {
- match file.stat() {
- Ok(stat)
- if bun_core::kind_from_mode(stat.st_mode as _) == bun_sys::FileKind::File
- && stat.st_size == 0 => {}
- Ok(_) => exit_unusable_package_json(UnusablePackageJson::Changed),
- Err(err) => exit_unusable_package_json(UnusablePackageJson::Stat(err)),
- }
-}
-
// ──────────────────────────────────────────────────────────────────────────
// Radio choice enums
// ──────────────────────────────────────────────────────────────────────────
@@ -2037,6 +1941,92 @@ static REACT_SHADCN_FILES: &[TemplateFile] = &[
// Helpers
// ──────────────────────────────────────────────────────────────────────────
+/// A package.json that exists but was not loaded has no variant: `bun init` exits instead.
+enum ExistingPackageJson {
+ /// The open failed with ENOENT.
+ Absent,
+ /// A regular file with no content, filled in through this handle.
+ Empty(bun_sys::File),
+ /// Read through this handle and parsed to an object.
+ Loaded(bun_sys::File),
+}
+
+/// Why `bun init` cannot use the package.json in the destination directory.
+enum UnusablePackageJson {
+ Open(bun_sys::Error),
+ Stat(bun_sys::Error),
+ NotRegularFile,
+ Read(bun_sys::Error),
+ Parse(bun_parsers::Error),
+ RootNotObject,
+ /// Absent or empty at the probe, but not an empty regular file at the write.
+ Changed,
+ Create(bun_sys::Error),
+}
+
+/// Absolute path of the destination directory's package.json.
+fn package_json_path(buf: &mut bun_paths::PathBuffer) -> &[u8] {
+ bun_paths::resolve_path::join_abs_string_buf::(
+ Fs::FileSystem::get().top_level_dir,
+ &mut buf[..],
+ &[b"package.json"],
+ )
+}
+
+#[cold]
+#[inline(never)]
+fn exit_unusable_package_json(cause: UnusablePackageJson) -> ! {
+ let mut path_buf = path_buffer_pool::get();
+ let path = bstr::BStr::new(package_json_path(&mut path_buf));
+ match cause {
+ UnusablePackageJson::Open(err)
+ if matches!(err.get_errno(), bun_sys::E::EACCES | bun_sys::E::EPERM) =>
+ {
+ Output::err(
+ err.name(),
+ "Permission denied while opening \"{s}\"",
+ &[&path],
+ );
+ bun_core::note!("package.json must be readable and writable for bun init to update it");
+ }
+ UnusablePackageJson::Open(err) => Output::err(&err, "could not open \"{s}\"", &[&path]),
+ UnusablePackageJson::Stat(err) => Output::err(&err, "could not stat \"{s}\"", &[&path]),
+ UnusablePackageJson::NotRegularFile => {
+ Output::err_generic("\"{s}\" is not a regular file", &[&path]);
+ }
+ UnusablePackageJson::Read(err) => Output::err(&err, "could not read \"{s}\"", &[&path]),
+ UnusablePackageJson::Parse(err) => {
+ Output::err(err, "failed to parse \"{s}\"", &[&path]);
+ bun_core::note!("fix or remove this file, then run 'bun init' again");
+ }
+ UnusablePackageJson::RootNotObject => {
+ Output::err_generic("package.json root must be an object in \"{s}\"", &[&path]);
+ bun_core::note!("fix or remove this file, then run 'bun init' again");
+ }
+ UnusablePackageJson::Changed => {
+ Output::err_generic(
+ "\"{s}\" appeared or changed while bun init was running",
+ &[&path],
+ );
+ bun_core::note!("run 'bun init' again");
+ }
+ UnusablePackageJson::Create(err) => {
+ Output::err(&err, "could not create \"{s}\"", &[&path]);
+ }
+ }
+ Global::exit(1);
+}
+
+fn exit_unless_empty_regular_file(file: &bun_sys::File) {
+ match file.stat() {
+ Ok(stat)
+ if bun_core::kind_from_mode(stat.st_mode as _) == bun_sys::FileKind::File
+ && stat.st_size == 0 => {}
+ Ok(_) => exit_unusable_package_json(UnusablePackageJson::Changed),
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Stat(err)),
+ }
+}
+
#[inline]
fn exists(path: &[u8]) -> bool {
bun_sys::exists(path)
diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts
index 4ca9ac6edc62..1b24d312920c 100644
--- a/test/cli/init/init.test.ts
+++ b/test/cli/init/init.test.ts
@@ -473,25 +473,21 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
devDependencies: { "@types/bun": "latest" },
});
expect(exitCode).toBe(0);
- }, 30_000);
+ });
// Creating a symlink needs a privilege on Windows.
- test.skipIf(isWindows)(
- "bun init -y -m creates the target of a dangling package.json symlink",
- async () => {
- await using temp = tempDir("bun-init-dangling-package-json", {});
- fs.symlinkSync("target.json", path.join(temp, "package.json"));
+ test.skipIf(isWindows)("bun init -y -m creates the target of a dangling package.json symlink", async () => {
+ await using temp = tempDir("bun-init-dangling-package-json", {});
+ fs.symlinkSync("target.json", path.join(temp, "package.json"));
- const { exitCode } = await init(temp, ["-y", "-m"]);
+ const { exitCode } = await init(temp, ["-y", "-m"]);
- expect(fs.readlinkSync(path.join(temp, "package.json"))).toBe("target.json");
- expect(await Bun.file(path.join(temp, "target.json")).json()).toEqual({
- devDependencies: { "@types/bun": "latest" },
- });
- expect(exitCode).toBe(0);
- },
- 30_000,
- );
+ expect(fs.readlinkSync(path.join(temp, "package.json"))).toBe("target.json");
+ expect(await Bun.file(path.join(temp, "target.json")).json()).toEqual({
+ devDependencies: { "@types/bun": "latest" },
+ });
+ expect(exitCode).toBe(0);
+ });
// The React templates never load package.json. Their writer skips one that exists.
test("bun init --react skips a package.json it cannot parse", async () => {
@@ -503,23 +499,19 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
expect(await Bun.file(path.join(temp, "package.json")).text()).toBe("{");
expect(fs.existsSync(path.join(temp, "bunfig.toml"))).toBe(true);
expect(exitCode).toBe(0);
- }, 30_000);
+ });
- test.skipIf(!permissionBitsApply)(
- "bun init --react skips a read-only package.json",
- async () => {
- const contents = '{ "name": "myapp" }\n';
- await using temp = tempDir("bun-init-react-read-only-package-json", { "package.json": contents });
- fs.chmodSync(path.join(temp, "package.json"), 0o444);
+ test.skipIf(!permissionBitsApply)("bun init --react skips a read-only package.json", async () => {
+ const contents = '{ "name": "myapp" }\n';
+ await using temp = tempDir("bun-init-react-read-only-package-json", { "package.json": contents });
+ fs.chmodSync(path.join(temp, "package.json"), 0o444);
- const { stdout, exitCode } = await init(temp, ["--react"]);
+ const { stdout, exitCode } = await init(temp, ["--react"]);
- expect(stdout.split("\n")).toContain(" ○ package.json (already exists, skipping)");
- expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(contents);
- expect(exitCode).toBe(0);
- },
- 30_000,
- );
+ expect(stdout.split("\n")).toContain(" ○ package.json (already exists, skipping)");
+ expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(contents);
+ expect(exitCode).toBe(0);
+ });
// ConPTY rewrites the child's output (it wraps lines at `cols`), so the text
// these tests read from the terminal is only stable on a POSIX pty.
From f4a08815810acb9bc4b00e6a76dedc5d84e191b3 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Sat, 3 Oct 2026 07:27:10 +0000
Subject: [PATCH 4/5] init: join the absolute path only on a parse error, wait
for the refusal on the pty
The probe parses under the relative name again. Only a parse error joins
the absolute path, in the reporter, and parses once more so that the
code frame names the file. A debug build counted one more allocation per
run with a valid package.json before this change.
The pty can deliver the last lines of the child after its exit is
reported. The three pty tests read the output at exit and failed about
1 run in 90 under load. They now wait for the last line of the refusal.
The docs sentence said that bun init exits with an error for every
package.json that it cannot load. With --react it skips the file.
---
docs/runtime/templating/init.mdx | 2 +-
src/runtime/cli/init_command.rs | 28 +++++++++++++++++-----------
test/cli/init/init.test.ts | 5 +++++
3 files changed, 23 insertions(+), 12 deletions(-)
diff --git a/docs/runtime/templating/init.mdx b/docs/runtime/templating/init.mdx
index 52875d288892..3d438b819d15 100644
--- a/docs/runtime/templating/init.mdx
+++ b/docs/runtime/templating/init.mdx
@@ -31,7 +31,7 @@ Press `enter` to accept the default answer for each prompt, or pass the `-y` fla
---
-`bun init` infers settings with sane defaults and is non-destructive when run multiple times. If a `package.json` exists but cannot be read or parsed, `bun init` exits with an error and leaves the file unchanged.
+`bun init` infers settings with sane defaults and is non-destructive when run multiple times. If a `package.json` exists but cannot be read or parsed, `bun init` leaves it unchanged and exits with an error. With `--react`, it skips the file instead.

diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs
index d18f9554baa5..6fc26be52c8d 100644
--- a/src/runtime/cli/init_command.rs
+++ b/src/runtime/cli/init_command.rs
@@ -470,19 +470,18 @@ impl InitCommand {
break 'probe ExistingPackageJson::Empty(pkg);
}
- let mut path_buf = path_buffer_pool::get();
let source = bun_ast::Source::init_path_string(
- package_json_path(&mut path_buf),
+ b"package.json",
package_json_contents.list.as_slice(),
);
let mut log = bun_ast::Log::init();
let package_json_expr: bun_ast::Expr =
match json::parse_package_json_utf8(&source, &mut log, &bump) {
Ok(e) => e,
- Err(err) => {
- let _ = log.print(std::ptr::from_mut(Output::error_writer()));
- exit_unusable_package_json(UnusablePackageJson::Parse(err));
- }
+ Err(err) => exit_unusable_package_json(UnusablePackageJson::Parse(
+ err,
+ package_json_contents.list.as_slice(),
+ )),
};
if !package_json_expr.data.is_e_object() {
@@ -1952,12 +1951,13 @@ enum ExistingPackageJson {
}
/// Why `bun init` cannot use the package.json in the destination directory.
-enum UnusablePackageJson {
+enum UnusablePackageJson<'a> {
Open(bun_sys::Error),
Stat(bun_sys::Error),
NotRegularFile,
Read(bun_sys::Error),
- Parse(bun_parsers::Error),
+ /// With the bytes of the file, for the code frame.
+ Parse(bun_parsers::Error, &'a [u8]),
RootNotObject,
/// Absent or empty at the probe, but not an empty regular file at the write.
Changed,
@@ -1975,9 +1975,10 @@ fn package_json_path(buf: &mut bun_paths::PathBuffer) -> &[u8] {
#[cold]
#[inline(never)]
-fn exit_unusable_package_json(cause: UnusablePackageJson) -> ! {
+fn exit_unusable_package_json(cause: UnusablePackageJson<'_>) -> ! {
let mut path_buf = path_buffer_pool::get();
- let path = bstr::BStr::new(package_json_path(&mut path_buf));
+ let path_bytes = package_json_path(&mut path_buf);
+ let path = bstr::BStr::new(path_bytes);
match cause {
UnusablePackageJson::Open(err)
if matches!(err.get_errno(), bun_sys::E::EACCES | bun_sys::E::EPERM) =>
@@ -1995,7 +1996,12 @@ fn exit_unusable_package_json(cause: UnusablePackageJson) -> ! {
Output::err_generic("\"{s}\" is not a regular file", &[&path]);
}
UnusablePackageJson::Read(err) => Output::err(&err, "could not read \"{s}\"", &[&path]),
- UnusablePackageJson::Parse(err) => {
+ UnusablePackageJson::Parse(err, contents) => {
+ // Parsed again so that the code frame names the absolute path.
+ let source = bun_ast::Source::init_path_string(path_bytes, contents);
+ let mut log = bun_ast::Log::init();
+ let _ = json::parse_package_json_utf8(&source, &mut log, &bun_alloc::Arena::new());
+ let _ = log.print(std::ptr::from_mut(Output::error_writer()));
Output::err(err, "failed to parse \"{s}\"", &[&path]);
bun_core::note!("fix or remove this file, then run 'bun init' again");
}
diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts
index 1b24d312920c..99dce87c47c3 100644
--- a/test/cli/init/init.test.ts
+++ b/test/cli/init/init.test.ts
@@ -521,12 +521,15 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
const decoder = new TextDecoder();
let output = "";
const picker = Promise.withResolvers();
+ const refused = Promise.withResolvers();
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
data(_, chunk: Uint8Array) {
output += decoder.decode(chunk, { stream: true });
if (output.includes("Select a project template")) picker.resolve();
+ // The last line of every refusal.
+ if (/'bun init' again\r?\n/.test(output)) refused.resolve();
},
});
await using proc = Bun.spawn({ cmd: [bunExe(), "init"], cwd, env: initEnv, terminal });
@@ -540,6 +543,8 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
onPicker(terminal);
}
const exitCode = await proc.exited;
+ // The pty can deliver the child's last lines after its exit is reported.
+ if (exitCode === 1) await refused.promise;
return { output: normalizeBunSnapshot(Bun.stripANSI(output), cwd), exitCode };
}
From 11caf93eb6fd6d3825ea5ace893e38bc4f143554 Mon Sep 17 00:00:00 2001
From: robobun <117481402+robobun@users.noreply.github.com>
Date: Tue, 6 Oct 2026 00:39:29 +0000
Subject: [PATCH 5/5] init: refuse when package.json was replaced during the
picker, read the pty until EOF in tests
An editor that saves by rename leaves bun init with a handle to the old,
unlinked file. That file is still an empty regular file, so the write step
filled it in, printed ` + package.json` and exited 0, and the saved file
got no scaffold. A link count of 0 now counts as changed.
The pty tests create the terminal in the spawn. Bun then delivers all of
the child's output before `exited` resolves, so the tests wait for no
marker, and a failure shows the output.
---
src/runtime/cli/init_command.rs | 6 ++-
test/cli/init/init.test.ts | 67 ++++++++++++++++++++-------------
2 files changed, 44 insertions(+), 29 deletions(-)
diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs
index 6fc26be52c8d..f49155d8a706 100644
--- a/src/runtime/cli/init_command.rs
+++ b/src/runtime/cli/init_command.rs
@@ -1959,7 +1959,7 @@ enum UnusablePackageJson<'a> {
/// With the bytes of the file, for the code frame.
Parse(bun_parsers::Error, &'a [u8]),
RootNotObject,
- /// Absent or empty at the probe, but not an empty regular file at the write.
+ /// Absent or empty at the probe. At the write it has content, or it was replaced.
Changed,
Create(bun_sys::Error),
}
@@ -2025,9 +2025,11 @@ fn exit_unusable_package_json(cause: UnusablePackageJson<'_>) -> ! {
fn exit_unless_empty_regular_file(file: &bun_sys::File) {
match file.stat() {
+ // No link left: the path was replaced, as an editor that saves by rename does.
Ok(stat)
if bun_core::kind_from_mode(stat.st_mode as _) == bun_sys::FileKind::File
- && stat.st_size == 0 => {}
+ && stat.st_size == 0
+ && stat.st_nlink != 0 => {}
Ok(_) => exit_unusable_package_json(UnusablePackageJson::Changed),
Err(err) => exit_unusable_package_json(UnusablePackageJson::Stat(err)),
}
diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts
index 99dce87c47c3..4f14a0b95bf8 100644
--- a/test/cli/init/init.test.ts
+++ b/test/cli/init/init.test.ts
@@ -521,31 +521,36 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
const decoder = new TextDecoder();
let output = "";
const picker = Promise.withResolvers();
- const refused = Promise.withResolvers();
- await using terminal = new Bun.Terminal({
- cols: 80,
- rows: 24,
- data(_, chunk: Uint8Array) {
- output += decoder.decode(chunk, { stream: true });
- if (output.includes("Select a project template")) picker.resolve();
- // The last line of every refusal.
- if (/'bun init' again\r?\n/.test(output)) refused.resolve();
+ // The spawn creates the terminal, so Bun delivers all of the child's output before
+ // `exited` resolves. A terminal that outlives the child can deliver the last lines later.
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "init"],
+ cwd,
+ env: initEnv,
+ terminal: {
+ cols: 80,
+ rows: 24,
+ data(_, chunk: Uint8Array) {
+ output += decoder.decode(chunk, { stream: true });
+ if (output.includes("Select a project template")) picker.resolve();
+ },
},
});
- await using proc = Bun.spawn({ cmd: [bunExe(), "init"], cwd, env: initEnv, terminal });
- if (onPicker) {
- // Fail with the child's output if it exits before the picker.
- const exitedEarly = proc.exited.then(code => {
- throw new Error(`bun init exited before the template picker (code ${code}):\n${output}`);
- });
- exitedEarly.catch(() => {});
- await Promise.race([picker.promise, exitedEarly]);
- onPicker(terminal);
+ try {
+ if (onPicker) {
+ // Fail with the child's output if it exits before the picker.
+ const exitedEarly = proc.exited.then(code => {
+ throw new Error(`bun init exited before the template picker (code ${code}):\n${output}`);
+ });
+ exitedEarly.catch(() => {});
+ await Promise.race([picker.promise, exitedEarly]);
+ onPicker(proc.terminal!);
+ }
+ const exitCode = await proc.exited;
+ return { output: normalizeBunSnapshot(Bun.stripANSI(output), cwd), exitCode };
+ } finally {
+ proc.terminal!.close();
}
- const exitCode = await proc.exited;
- // The pty can deliver the child's last lines after its exit is reported.
- if (exitCode === 1) await refused.promise;
- return { output: normalizeBunSnapshot(Bun.stripANSI(output), cwd), exitCode };
}
test("bun init errors on an invalid package.json before the template picker", async () => {
@@ -564,17 +569,25 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
});
test.each([
- { name: "created", before: undefined },
- { name: "given content", before: "" },
- ])("bun init keeps a package.json that is $name while the template picker is open", async ({ before }) => {
+ { name: "created", before: undefined, rename: false },
+ { name: "given content", before: "", rename: false },
+ // An editor that saves by rename leaves the old, empty file open in `bun init`.
+ { name: "replaced by a rename", before: "", rename: true },
+ ])("bun init keeps a package.json that is $name while the template picker is open", async ({ before, rename }) => {
const saved = '{ "name": "saved-during-the-picker" }\n';
await using temp = tempDir(
"bun-init-tty-package-json-changed",
before === undefined ? {} : { "package.json": before },
);
+ const packageJson = path.join(temp, "package.json");
const { output, exitCode } = await initOnTTY(temp, terminal => {
- fs.writeFileSync(path.join(temp, "package.json"), saved);
+ if (rename) {
+ fs.writeFileSync(packageJson + ".tmp", saved);
+ fs.renameSync(packageJson + ".tmp", packageJson);
+ } else {
+ fs.writeFileSync(packageJson, saved);
+ }
// "1" picks the first entry (Blank) and submits it.
terminal.write("1");
});
@@ -582,7 +595,7 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
expect(output).toEndWith(
'error: "/package.json" appeared or changed while bun init was running\n' + "note: run 'bun init' again",
);
- expect(await Bun.file(path.join(temp, "package.json")).text()).toBe(saved);
+ expect(await Bun.file(packageJson).text()).toBe(saved);
expect(readdirSync(temp)).toEqual(["package.json"]);
expect(exitCode).toBe(1);
});