diff --git a/docs/runtime/templating/init.mdx b/docs/runtime/templating/init.mdx index 926ac0514954..c9e5bfdb70db 100644 --- a/docs/runtime/templating/init.mdx +++ b/docs/runtime/templating/init.mdx @@ -41,7 +41,7 @@ It creates: - a `package.json` file with a name that defaults to the current directory name, and a `typecheck` script that runs [`bun check`](/runtime/check) - a `tsconfig.json` or `jsconfig.json` file, depending on whether the entry point is a TypeScript file -- an entry point, which defaults to `index.ts` unless any of `index.{tsx, jsx, js, mts, mjs}` exist or the `package.json` specifies a `module` or `main` field +- an entry point, which defaults to `index.ts` unless any of `index.{tsx, jsx, js, mts, mjs}` exist or the `package.json` specifies a `module` or `main` field. An entry point that `package.json` names is only created when it is a JavaScript or TypeScript file and the package does not resolve to a module yet, and never with `--minimal` - a `README.md` file AI Agent rules (disable with `$BUN_AGENT_RULE_DISABLED=1`): diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index ac428b724a8c..7f955a72a4b6 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -13,7 +13,8 @@ use bun_resolver::fs as Fs; use bun_sys::{self, Fd}; use crate::cli as CLI; -use bun_bundler::options; +use bun_bundler::{Transpiler, options}; +use bun_options_types::schema::api; // ────────────────────────────────────────────────────────────────────────── // RadioChoice trait — the choice enums used by `process_radio_button` @@ -399,8 +400,11 @@ impl InitCommand { 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 package_json_open = + bun_sys::File::openat(destination_dir, b"package.json", bun_sys::O::RDWR, 0); + let package_json_missing = + matches!(&package_json_open, Err(err) if err.get_errno() == bun_sys::E::ENOENT); + let mut package_json_file: Option = package_json_open.ok(); let mut package_json_contents: MutableString = MutableString::init_empty(); bun_ast::initialize_store(); // Arena for JSON parse / Expr building. @@ -465,6 +469,8 @@ impl InitCommand { b"project".to_vec() }; let mut did_load_package_json = false; + let mut entry_from_manifest = false; + let mut longest_main_field: usize = 0; if !package_json_contents.list.is_empty() { 'process_package_json: { let source = bun_ast::Source::init_path_string( @@ -500,6 +506,15 @@ impl InitCommand { { if let Some(str_) = name.as_utf8_string_literal() { fields.entry_point = str_.to_vec(); + entry_from_manifest = !fields.entry_point.is_empty(); + } + } + for main_field in [b"module".as_slice(), b"main", b"jsnext:main"] { + if let Some(len) = package_json_expr + .get(main_field) + .and_then(|value| value.as_utf8_string_literal().map(<[u8]>::len)) + { + longest_main_field = longest_main_field.max(len); } } @@ -798,17 +813,7 @@ 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 _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); @@ -827,37 +832,42 @@ impl InitCommand { }, ); if let Err(err) = print_result { - bun_core::pretty_errorln!( - "package.json failed to write due to error {}", - err.name(), - ); - package_json_file = None; - break 'write_package_json; + Output::err(err, "failed to write package.json", ()); + Global::exit(1); } let written = package_json_writer.ctx.get_written(); - if let Err(err) = bun_sys::File::borrow(&fd).write_all(written) { - 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 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; + let write_result = match package_json_file.as_ref() { + // Read with `pread`, so the offset is still 0. + Some(file) => file.write_all(written).and_then(|()| { + bun_sys::ftruncate( + file.handle(), + i64::try_from(written.len()).expect("int cast"), + ) + }), + None if package_json_missing => match Assets::write_new_file( + b"package.json", + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::EXCL, + written, + ) { + // A symlink to a file that does not exist yet: write through it. + Err(err) if err.get_errno() == bun_sys::E::EEXIST => { + Assets::overwrite(b"package.json", written) + } + result => result, + }, + // Exists but could not be opened, read, or parsed as an object: start it over in place. + None => Assets::overwrite(b"package.json", written), + }; + if let Err(err) = write_result { + Assets::failed(err, b"package.json"); } } if steps.write_gitignore { - let _ = Assets::create(b".gitignore", Assets::GITIGNORE, &[]); - // suppressed + Assets::check( + Assets::create(b".gitignore", Assets::GITIGNORE, &[]), + b".gitignore", + ); } match template { @@ -866,56 +876,54 @@ impl InitCommand { Template::create_agent_rule(); } - if package_json_file.is_some() && !did_load_package_json { + if !did_load_package_json { bun_core::prettyln!(" + package.json"); Output::flush(); } - if !fields.entry_point.is_empty() + if !minimal + && !fields.entry_point.is_empty() && is_safe_entry_point_path(&fields.entry_point) + && (!entry_from_manifest || is_script_file_name(&fields.entry_point)) && !exists(&fields.entry_point) + && !(entry_from_manifest && package_entry_loads(longest_main_field)) { - if let Some(dirname) = bun_core::dirname(&fields.entry_point) { - if dirname != b"." { - let _ = bun_sys::Dir::cwd().make_path(dirname); - } + let created = Assets::create_new( + &fields.entry_point, + b"console.log(\"Hello via Bun!\");\n", + ); + // A project that already has a package.json works without the stub. + if !entry_from_manifest { + Assets::check(created, &fields.entry_point); } - - let mut ep_z = fields.entry_point.clone(); - ep_z.push(0); - let ep_zstr = ZStr::from_slice_with_nul(&ep_z[..]); - // SAFETY: ep_z[len-1] == 0 written above - let _ = Assets::create_new(ep_zstr, b"console.log(\"Hello via Bun!\");\n"); - // suppress } if steps.write_tsconfig { - 'brk: { - let (filename, what_for): (&[u8], _) = - if is_type_script_entry_point(&fields.entry_point) { - (b"tsconfig.json", " (for bun check and editor autocomplete)") - } else { - (b"jsconfig.json", " (for editor autocomplete)") - }; - if Assets::create_full(Assets::TSCONFIG_JSON, filename, what_for, &[]) - .is_err() - { - break 'brk; - } - } + let (filename, what_for): (&[u8], _) = + if is_type_script_entry_point(&fields.entry_point) { + (b"tsconfig.json", " (for bun check and editor autocomplete)") + } else { + (b"jsconfig.json", " (for editor autocomplete)") + }; + Assets::check( + Assets::create_full(Assets::TSCONFIG_JSON, filename, what_for, &[]), + filename, + ); } if steps.write_readme { - let _ = Assets::create( + Assets::check( + Assets::create( + b"README.md", + Assets::README_MD, + &[ + (b"name", fields.name.as_slice()), + (b"bunVersion", Environment::VERSION_STRING.as_bytes()), + (b"entryPoint", fields.entry_point.as_slice()), + ], + ), b"README.md", - Assets::README_MD, - &[ - (b"name", fields.name.as_slice()), - (b"bunVersion", Environment::VERSION_STRING.as_bytes()), - (b"entryPoint", fields.entry_point.as_slice()), - ], ); - // suppressed } if !fields.entry_point.is_empty() && !did_load_package_json { @@ -966,82 +974,41 @@ impl Assets { pub(crate) const README_MD: &'static [u8] = include_bytes!("init/README.default.md"); pub(crate) const README2_MD: &'static [u8] = include_bytes!("init/README2.default.md"); - /// Create a new asset file, overriding anything that already exists. Known - /// assets will have their contents pre-populated; otherwise the file will be empty. - /// - /// Takes the asset bytes directly; `asset_name` is the filename. - fn create( - asset_name: &[u8], - asset: &'static [u8], - args: &[(&[u8], &[u8])], - ) -> Result<(), Error> { - let is_template = !args.is_empty(); - Self::create_full_inner(asset, asset_name, "", is_template, args) + /// Create an asset file (`EEXIST` if it exists). With `args`, `{[name]s}` placeholders are substituted. + fn create(filename: &[u8], asset: &[u8], args: &[(&[u8], &[u8])]) -> bun_sys::Result<()> { + Self::create_full(asset, filename, "", args) } - pub(crate) fn create_with_contents( - asset_name: &[u8], - contents: &'static [u8], - args: &[(&[u8], &[u8])], - ) -> Result<(), Error> { - let is_template = !args.is_empty(); - Self::create_full_with_contents(asset_name, contents, "", is_template, args) - } - - fn create_new(filename: &ZStr, contents: &[u8]) -> Result<(), Error> { - // Create parent dirs then open. - if let Some(dir) = bun_core::dirname(filename.as_bytes()) { + /// Like [`create`](Self::create) without substitution; creates missing parent directories. + fn create_new(filename: &[u8], contents: &[u8]) -> bun_sys::Result<()> { + if let Some(dir) = bun_core::dirname(filename) { if !dir.is_empty() && dir != b"." { - let _ = bun_sys::Dir::cwd().make_path(dir); + bun_sys::Dir::cwd().make_path(dir)?; } } - let file = bun_sys::File::openat( - Fd::cwd(), - filename.as_bytes(), + Self::write_new_file( + filename, bun_sys::O::CREAT | bun_sys::O::EXCL | bun_sys::O::WRONLY, - 0o666, + contents, )?; - file.write_all(contents)?; - - bun_core::prettyln!(" + {}", bstr::BStr::new(filename.as_bytes())); + bun_core::prettyln!(" + {}", bstr::BStr::new(filename)); Output::flush(); Ok(()) } fn create_full( - // content of known asset - asset: &'static [u8], - // name of asset file to create + asset: &[u8], filename: &[u8], // optionally add a suffix to the end of the `+ filename` message. Must have a leading space. message_suffix: &'static str, args: &[(&[u8], &[u8])], - ) -> Result<(), Error> { - let is_template = !args.is_empty(); - Self::create_full_inner(asset, filename, message_suffix, is_template, args) - } - - fn create_full_inner( - asset: &'static [u8], - filename: &[u8], - message_suffix: &'static str, - is_template: bool, - args: &[(&[u8], &[u8])], - ) -> Result<(), Error> { - let file = bun_sys::File::openat( - Fd::cwd(), - filename, - bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, - 0o666, - )?; - - // Write contents of known assets to the new file. Template assets get formatted. - if is_template { - let buf = bun_fmt::substitute_named(asset, args); - file.write_all(&buf)?; + ) -> bun_sys::Result<()> { + let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::EXCL; + if args.is_empty() { + Self::write_new_file(filename, flags, asset)?; } else { - file.write_all(asset)?; + Self::write_new_file(filename, flags, &bun_fmt::substitute_named(asset, args))?; } bun_core::prettyln!( " + {}{}", @@ -1052,38 +1019,45 @@ impl Assets { Ok(()) } - fn create_full_with_contents( - // name of asset file to create - filename: &[u8], - contents: &'static [u8], - // optionally add a suffix to the end of the `+ filename` message. Must have a leading space. - message_suffix: &'static str, - // Treat the asset as a format string, using `args` to populate it. Only applies to known assets. - is_template: bool, - // Format arguments - args: &[(&[u8], &[u8])], - ) -> Result<(), Error> { - let file = bun_sys::File::openat( - Fd::cwd(), - filename, - bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, - 0o666, - )?; + /// `flags` include `O_EXCL`; a file whose write fails is removed again so a rerun creates it. + fn write_new_file(filename: &[u8], flags: i32, contents: &[u8]) -> bun_sys::Result<()> { + let file = bun_sys::File::openat(Fd::cwd(), filename, flags, 0o666) + .map_err(|err| err.with_path(filename))?; + let result = file.write_all(contents); + drop(file); + if result.is_err() { + let mut filename_z = filename.to_vec(); + filename_z.push(0); + let _ = bun_sys::unlinkat(Fd::cwd(), ZStr::from_slice_with_nul(&filename_z)); + } + result.map_err(|err| err.with_path(filename)) + } - if is_template { - let buf = bun_fmt::substitute_named(contents, args); - file.write_all(&buf)?; - } else { - file.write_all(contents)?; + /// Replace a file's contents in place. A failure leaves the file where it is. + fn overwrite(filename: &[u8], contents: &[u8]) -> bun_sys::Result<()> { + let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; + bun_sys::File::openat(Fd::cwd(), filename, flags, 0o666) + .and_then(|file| file.write_all(contents)) + .map_err(|err| err.with_path(filename)) + } + + /// Whether the file was created; an existing one is left alone, any other failure exits. + fn check(result: bun_sys::Result<()>, filename: &[u8]) -> bool { + match result { + Ok(()) => true, + Err(err) if err.get_errno() == bun_sys::E::EEXIST => false, + Err(err) => Self::failed(err, filename), } + } - bun_core::prettyln!( - " + {}{}", - bstr::BStr::new(filename), - message_suffix, + /// Report a scaffold file that could not be written and exit(1). + fn failed(err: bun_sys::Error, filename: &[u8]) -> ! { + Output::err( + err, + "failed to write {}", + (bstr::BStr::new(filename),), ); - Output::flush(); - Ok(()) + Global::exit(1); } } @@ -1475,6 +1449,11 @@ impl Template { } fn create_agent_rule() { + /// The rule is an extra for a detected editor: one that cannot be written is skipped. + fn create_rule(path: &[u8], contents: &[u8]) -> bool { + Assets::create_new(path, contents).is_ok() + } + let mut create_claude_md = Self::is_claude_code_installed() // Never overwrite CLAUDE.md && !exists(b"CLAUDE.md"); @@ -1487,28 +1466,11 @@ impl Template { } else { template_file.path }; - let asset_path_z = { - let mut v = asset_path.to_vec(); - v.push(0); - v - }; - let result = Assets::create_new( - ZStr::from_slice_with_nul(&asset_path_z[..]), - // SAFETY: asset_path_z[len-1] == 0 written above - template_file.contents, - ); - if result.is_err() { - if create_claude_md { - create_claude_md = false; - // If installing the CLAUDE.md fails for some reason, fall back to installing the cursor rule. - let mut tp = template_file.path.to_vec(); - tp.push(0); - let _ = Assets::create_new( - ZStr::from_slice_with_nul(&tp[..]), - // SAFETY: tp[len-1] == 0 written above - template_file.contents, - ); - } + let created = create_rule(asset_path, template_file.contents); + if !created && create_claude_md { + // If installing the CLAUDE.md fails for some reason, fall back to installing the cursor rule. + create_claude_md = false; + create_rule(template_file.path, template_file.contents); } #[cfg(not(windows))] @@ -1518,7 +1480,7 @@ impl Template { // sync if you change it locally. we use a symlink for the cursor // rule in this case so that the github UI for CLAUDE.md (which may // appear prominently in repos) doesn't show a file path. - if result.is_ok() && create_claude_md { + if created && create_claude_md { 'symlink_cursor_rule: { create_claude_md = false; let _ = bun_sys::Dir::cwd().make_path(b".cursor/rules"); @@ -1553,9 +1515,8 @@ impl Template { None => 0, }; - let _ = Assets::create_new( - ZStr::from_static(b"CLAUDE.md\0"), - // SAFETY: literal is NUL-terminated + create_rule( + b"CLAUDE.md", Self::AGENT_RULE[end_of_frontmatter..].trim_ascii_start(), ); } @@ -1641,7 +1602,7 @@ impl Template { let contents = file.contents; let result = if path == b"README.md" { - Assets::create_with_contents( + Assets::create( b"README.md", contents, &[ @@ -1650,28 +1611,17 @@ impl Template { ], ) } else { - let mut p = path.to_vec(); - p.push(0); - Assets::create_new( - ZStr::from_slice_with_nul(&p[..]), - // SAFETY: p[len-1] == 0 written above - contents, - ) + Assets::create_new(path, contents) }; if let Err(err) = result { - if matches!(err, crate::Error::Sys(bun_errno::SystemErrno::EEXIST)) { + if err.get_errno() == bun_sys::E::EEXIST { bun_core::prettyln!( " ○ {} (already exists, skipping)", bstr::BStr::new(path), ); Output::flush(); } else { - Output::err( - err, - "failed to create file: '{s}'", - &[&bstr::BStr::new(path)], - ); - Global::crash(); + Assets::failed(err, path); } } } @@ -1945,6 +1895,38 @@ fn is_safe_entry_point_path(path: &[u8]) -> bool { !bun_paths::is_absolute_loose(path) && !strings::split_any(path, b"/\\").any(|seg| seg == b"..") } +/// Whether `path` names a JavaScript or TypeScript file, the only kind of file the stub can be. +fn is_script_file_name(path: &[u8]) -> bool { + !matches!(path.last(), Some(b'/' | b'\\')) + && bun_ast::Loader::from_string(bun_paths::extension(path)) + .is_some_and(|loader| loader.is_java_script_like()) +} + +/// Whether `bun .` finds a module for the package in the current directory; undecidable counts as found. +#[cold] +fn package_entry_loads(longest_main_field: usize) -> bool { + let top_level_dir = Fs::FileSystem::get().top_level_dir; + // The resolver joins the directory and a main field into a path buffer without a length check. + if top_level_dir.len() + longest_main_field + 64 > bun_paths::MAX_PATH_BYTES { + return true; + } + let mut log = bun_ast::Log::init(); + let options = api::TransformOptions { + target: Some(api::Target::Bun), + ..Default::default() + }; + let Ok(mut transpiler) = Transpiler::init(CLI::cli_arena(), &raw mut log, options, None) else { + return true; + }; + transpiler.resolver.opts.load_tsconfig_json = false; + !matches!( + transpiler + .resolver + .resolve(top_level_dir, b".", bun_ast::ImportKind::EntryPointRun), + Err(bun_resolver::Error::ModuleNotFound) + ) +} + #[inline] fn exists_z(path: &[u8]) -> bool { bun_sys::exists(path) diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 113be9d6abcc..c40cecae62dd 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -628,3 +628,286 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(fs.existsSync(path.join(temp, ".cursor"))).toBe(false); }); }); + +// A scaffold file that cannot be written (full disk) must fail `bun init` +// instead of being left behind empty with exit code 0. bun ignores SIGXFSZ, +// so under `ulimit -f N` a write past the limit fails with EFBIG, which takes +// the same path as ENOSPC. +describe.skipIf(isWindows)("bun init when a file cannot be written", () => { + // `ulimit -f 1` lets a file grow to 512 bytes in dash, bash and busybox, and + // to 1024 bytes in the /bin/sh of macOS. + const ulimitBlock = isWindows ? 0 : measureUlimitBlock(); + function measureUlimitBlock() { + using dir = tempDir("bun-init-ulimit-block", {}); + Bun.spawnSync({ + cmd: ["sh", "-c", "trap '' XFSZ; ulimit -f 1; head -c 4096 /dev/zero > probe"], + cwd: String(dir), + stdout: "ignore", + stderr: "ignore", + }); + return fs.statSync(path.join(String(dir), "probe")).size; + } + + // Lists the dependencies, so that `bun init` runs no `bun install`. + const withDependencies = JSON.stringify({ + name: "pkg", + devDependencies: { "@types/bun": "latest" }, + peerDependencies: { typescript: "^7" }, + }); + + async function init(cwd: string, options: { limit?: number; env?: Record } = {}) { + const cmd = + options.limit === undefined + ? [bunExe(), "init", "-y"] + : ["sh", "-c", `ulimit -f ${options.limit} && exec "$@"`, "sh", bunExe(), "init", "-y"]; + await using proc = Bun.spawn({ + cmd, + cwd, + env: options.env ?? initEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + /** Every file below `dir` with its size (directories themselves are left out). */ + function files(dir: string) { + return Object.fromEntries( + readdirSync(dir, { recursive: true, encoding: "utf8" }) + .map(name => [name, fs.statSync(path.join(dir, name))] as const) + .filter(([, stat]) => stat.isFile()) + .map(([name, stat]) => [name, stat.size]), + ); + } + + test.concurrent("package.json: reports the error, removes the empty file, exits 1", async () => { + using dir = tempDir("bun-init-enospc-package-json", {}); + // No file may grow past 0 bytes: the very first write fails. + const { stderr, exitCode } = await init(String(dir), { limit: 0 }); + expect(stderr).toContain("EFBIG"); + expect(stderr).toContain("failed to write package.json"); + expect(files(String(dir))).toEqual({}); + expect(exitCode).toBe(1); + }); + + // tsconfig.json (735 bytes) is the one scaffold file between 512 and 1024 + // bytes, so this needs a shell that counts `ulimit -f` in 512-byte blocks. + (ulimitBlock === 512 ? test.concurrent : test.skip)( + "tsconfig.json: reports the error, removes the partial file, exits 1, and a rerun completes the scaffold", + async () => { + using dir = tempDir("bun-init-enospc-tsconfig", { "package.json": withDependencies }); + { + const { stdout, stderr, exitCode } = await init(String(dir), { limit: 1 }); + expect(stderr).toContain("EFBIG"); + expect(stderr).toContain("failed to write tsconfig.json"); + expect(stdout).not.toContain("tsconfig.json"); + const written = files(String(dir)); + expect(written).toEqual({ + ".gitignore": expect.any(Number), + "index.ts": expect.any(Number), + "package.json": expect.any(Number), + }); + expect(Object.values(written)).not.toContain(0); + expect(exitCode).toBe(1); + } + // With space available again the missing files are created. + { + const { stdout, stderr, exitCode } = await init(String(dir)); + expect(stderr).not.toContain("failed to write"); + expect(stdout).toContain("+ tsconfig.json"); + expect(stdout).toContain("+ README.md"); + const written = files(String(dir)); + expect(written).toMatchObject({ "tsconfig.json": expect.any(Number), "README.md": expect.any(Number) }); + expect(Object.values(written)).not.toContain(0); + expect(exitCode).toBe(0); + } + }, + 30_000, + ); + + // The Cursor rule is written for a detected editor, not asked for. It is + // larger than two `ulimit -f` blocks of either size, and every other scaffold + // file is smaller than two blocks. + test.concurrent("an agent rule that cannot be written is skipped and leaves no partial file", async () => { + using dir = tempDir("bun-init-enospc-agent-rule", { "package.json": withDependencies }); + const env = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "0", CLAUDE_CODE_AGENT_RULE_DISABLED: "1", CURSOR_TRACE_ID: "1" }; + const { stdout, stderr, exitCode } = await init(String(dir), { limit: 2, env }); + expect(stderr).not.toContain("failed to write"); + expect(stdout).not.toContain(".cursor"); + const written = files(String(dir)); + expect(written).toEqual({ + ".gitignore": expect.any(Number), + "README.md": expect.any(Number), + "index.ts": expect.any(Number), + "package.json": expect.any(Number), + "tsconfig.json": expect.any(Number), + }); + expect(Object.values(written)).not.toContain(0); + expect(exitCode).toBe(0); + }); + + test.concurrent("a package.json that cannot be opened is reported with the cause", async () => { + using dir = tempDir("bun-init-package-json-is-a-directory", { "package.json": {} }); + const { stderr, exitCode } = await init(String(dir)); + expect(stderr).toContain("EISDIR"); + expect(stderr).toContain("failed to write package.json"); + expect(exitCode).toBe(1); + }); + + test.concurrent( + "a package.json that is a symlink to a missing file is written through the link", + async () => { + using dir = tempDir("bun-init-package-json-symlink", {}); + fs.symlinkSync("real-package.json", path.join(String(dir), "package.json")); + // A new project runs `bun install`. Keep it off the network and let it fail fast. + using registry = Bun.serve({ port: 0, fetch: () => new Response("not found", { status: 404 }) }); + const env = { + ...initEnv, + BUN_CONFIG_REGISTRY: String(registry.url), + BUN_INSTALL_CACHE_DIR: path.join(String(dir), ".cache"), + }; + const { stderr } = await init(String(dir), { env }); + expect(stderr).not.toContain("failed to write"); + expect(await Bun.file(path.join(String(dir), "real-package.json")).json()).toMatchObject({ module: "index.ts" }); + }, + 30_000, + ); +}); + +// `bun init` in a directory that already has a package.json takes the entry +// point from `module` or `main`. It creates the "Hello via Bun!" stub at that +// path only when the path names a script and nothing loads for the package +// yet. It used to create it whenever that exact path did not exist, and the +// stub then ran in place of the module the package resolved to before. The +// fixtures list the dependencies, so no `bun install` runs. +describe("bun init over a package.json that names an entry point", () => { + const stubSource = 'console.log("Hello via Bun!");\n'; + const scaffold = new Set([".gitignore", "README.md", "package.json", "tsconfig.json", "jsconfig.json"]); + + function manifest(fields: Record) { + return JSON.stringify({ + name: "pkg", + version: "1.0.0", + ...fields, + devDependencies: { "@types/bun": "latest" }, + peerDependencies: { typescript: "^7" }, + }); + } + + async function bun(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: initEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + /** Everything below `dir` that is not one of init's own scaffold files, directories included. */ + function leftBehind(dir: string) { + return readdirSync(dir, { recursive: true, encoding: "utf8" }) + .map(name => name.replaceAll("\\", "/")) + .filter(name => !scaffold.has(name)) + .sort(); + } + + describe.each([ + // [what, package.json fields, the module that `bun .` runs, where the stub used to be written] + ["an extensionless main beside index.js", { main: "./index" }, "index.js", "index"], + ["a .js main beside index.ts", { main: "./index.js" }, "index.ts", "index.js"], + ["a missing main beside index.js", { main: "./missing.js" }, "index.js", "missing.js"], + ["a missing module beside a main", { module: "./missing.mjs", main: "./real.js" }, "real.js", "missing.mjs"], + ] as const)("%s", (_what, fields, real, stub) => { + test.concurrent.each([ + ["-y", ["-y"], ""], + ["--minimal -y", ["--minimal", "-y"], ""], + ["-y ", ["-y", "pkg"], "pkg"], + ] as const)( + "`bun .` still runs that module after bun init %s", + async (_flags, args, folder) => { + const project = { "package.json": manifest(fields), [real]: `console.log("the real module");` }; + using dir = tempDir("bun-init-entry-shadow", folder ? { [folder]: project } : project); + const cwd = path.join(String(dir), folder); + + const init = await bun(String(dir), "init", ...args); + const run = await bun(cwd, "."); + + expect(init.stdout).not.toContain(" + ./" + stub); + expect({ + ran: run.stdout, + stubCreated: fs.existsSync(path.join(cwd, stub)), + exitCode: init.exitCode, + }).toEqual({ ran: "the real module\n", stubCreated: false, exitCode: 0 }); + }, + 30_000, + ); + }); + + // The documented case: the entry point named by package.json is created when + // it is a script and the package does not load yet. + test.concurrent.each([ + ["main", "dist/index.js"], + ["module", "index.ts"], + ] as const)("creates the stub at a %s that nothing loads for yet", async (field, entry) => { + using dir = tempDir("bun-init-entry-create", { "package.json": manifest({ [field]: entry }) }); + const init = await bun(String(dir), "init", "-y"); + expect(init.stdout).toContain(" + " + entry); + expect(fs.readFileSync(path.join(String(dir), entry), "utf8")).toBe(stubSource); + expect(init.exitCode).toBe(0); + }); + + test.concurrent("--minimal creates no stub at a main that nothing loads for yet", async () => { + using dir = tempDir("bun-init-entry-minimal", { "package.json": manifest({ main: "./missing.js" }) }); + const init = await bun(String(dir), "init", "--minimal", "-y"); + expect(leftBehind(String(dir))).toEqual([]); + expect(init.exitCode).toBe(0); + }); + + test.concurrent.each([ + ["data.json", {}], + ["build/Release/addon.node", {}], + ["bin/cli", {}], + ["./addon", { "addon.node": "" }], + ] as const)("creates nothing at main %s, which does not name a script", async (main, files) => { + using dir = tempDir("bun-init-entry-not-script", { "package.json": manifest({ main }), ...files }); + const init = await bun(String(dir), "init", "-y"); + expect(leftBehind(String(dir))).toEqual(Object.keys(files)); + expect(init.exitCode).toBe(0); + }); + + test.concurrent( + "a main that cannot be created is skipped: nothing is left behind and init exits 0", + async () => { + const long = Buffer.alloc(4096, "m").toString(); + const rows: { main: string; files: Record }[] = [ + { main: "./lib/", files: {} }, + { main: "a/b/", files: {} }, + { main: "f/child.js", files: { f: "a file, not a directory" } }, + { main: long + ".js", files: {} }, + // Whether the package loads is decided without reading tsconfig.json. + { main: "./index.js", files: { "index.ts": "", "tsconfig.json": JSON.stringify({ extends: "./" + long }) } }, + ]; + const results = await Promise.all( + rows.map(async ({ main, files }) => { + using dir = tempDir("bun-init-entry-skip", { "package.json": manifest({ main }), ...files }); + const init = await bun(String(dir), "init", "-y"); + return { main: main.slice(0, 16), left: leftBehind(String(dir)), exitCode: init.exitCode }; + }), + ); + expect(results).toEqual( + rows.map(({ main, files }) => ({ + main: main.slice(0, 16), + left: Object.keys(files).filter(name => !scaffold.has(name)), + exitCode: 0, + })), + ); + }, + 30_000, + ); +});