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. ![Demo](https://user-images.githubusercontent.com/709451/183006613-271960a3-ff22-4f7c-83f5-5e18f684c836.gif) 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", {});