diff --git a/docs/runtime/templating/init.mdx b/docs/runtime/templating/init.mdx
index f50c20d6aca6..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.
+`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 8b23c6274da9..f49155d8a706 100644
--- a/src/runtime/cli/init_command.rs
+++ b/src/runtime/cli/init_command.rs
@@ -393,64 +393,24 @@ impl InitCommand {
}
let _ = Fs::FileSystem::init(None)?;
+
+ // 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(());
+ }
+
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 pkg
- .pread_all(package_json_contents.list.as_mut_slice(), 0)
- .is_err()
- {
- package_json_file = None;
- break 'read_package_json;
- }
- #[cfg(windows)]
- pkg.seek_to(prev_file_pos)?;
- }
- }
fields.name = 'brk: {
if let Ok(name) = Self::normalize_package_name(if !pathname.filename.is_empty() {
@@ -464,48 +424,89 @@ impl InitCommand {
}
b"project".to_vec()
};
- 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;
- }
- };
-
- if !package_json_expr.data.is_e_object() {
- package_json_file = None;
- break 'process_package_json;
+ 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);
+ }
- fields.object = package_json_expr.data.e_object();
+ let size = usize::try_from(stat.st_size).expect("int cast");
+ package_json_contents = MutableString::init(size)?;
+ package_json_contents.list.resize(size, 0);
- if let Some(name) = package_json_expr.get(b"name") {
- if let Some(str) = name.as_utf8_string_literal() {
- fields.name = str.to_vec();
- }
- }
+ #[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);
+ }
- 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();
- }
+ 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) => exit_unusable_package_json(UnusablePackageJson::Parse(
+ err,
+ package_json_contents.list.as_slice(),
+ )),
+ };
+
+ if !package_json_expr.data.is_e_object() {
+ exit_unusable_package_json(UnusablePackageJson::RootNotObject);
+ }
+
+ 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();
}
+ }
- did_load_package_json = true;
+ 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();
+ }
}
- }
+
+ ExistingPackageJson::Loaded(pkg)
+ };
if fields.entry_point.is_empty() && !minimal {
'infer: {
@@ -560,14 +561,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 +782,50 @@ 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, 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 +848,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 +856,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 +883,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 +946,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() {
@@ -1902,6 +1940,101 @@ 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<'a> {
+ Open(bun_sys::Error),
+ Stat(bun_sys::Error),
+ NotRegularFile,
+ Read(bun_sys::Error),
+ /// With the bytes of the file, for the code frame.
+ Parse(bun_parsers::Error, &'a [u8]),
+ RootNotObject,
+ /// Absent or empty at the probe. At the write it has content, or it was replaced.
+ 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_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) =>
+ {
+ 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, 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");
+ }
+ 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() {
+ // 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_nlink != 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 737c955cee53..4f14a0b95bf8 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,6 +311,296 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" };
});
}, 30_000);
+ // `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:
+ '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: "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,
+ });
+
+ 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("");
+ // 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)).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);
+ });
+
+ // 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);
+ });
+
+ // 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);
+ });
+
+ 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);
+ });
+
+ // 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();
+ // 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();
+ },
+ },
+ });
+ 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();
+ }
+ }
+
+ 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, 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 => {
+ 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");
+ });
+
+ expect(output).toEndWith(
+ 'error: "/package.json" appeared or changed while bun init was running\n' + "note: run 'bun init' again",
+ );
+ expect(await Bun.file(packageJson).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", {});