From 940a06868551f2bfa5d224c4f128f8b85345f47b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:26:26 +0000 Subject: [PATCH 1/9] init: report a scaffold file that cannot be written and exit 1 Each template file that bun init writes (package.json, .gitignore, index.ts, tsconfig.json, README.md, CLAUDE.md / the cursor rule) went through a helper whose Result the call sites dropped. On a full disk the open succeeded, the write failed with ENOSPC, and init exited 0 with the files left at 0 bytes. A rerun skipped them because they existed. The helpers now share one writer that removes the file again when the write fails. Every call site prints the errno and the file name and exits 1. An open failure (inode exhaustion, EACCES) gets the same message instead of "An internal error occurred (ENOSPC)". --- src/runtime/cli/init_command.rs | 304 ++++++++++++-------------------- test/cli/init/init.test.ts | 65 +++++++ 2 files changed, 173 insertions(+), 196 deletions(-) diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index c0b91fe8b482..0a1eebea3e76 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -781,17 +781,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); @@ -810,37 +800,33 @@ 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 => Assets::write_new_file( + b"package.json", + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, + 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 + if let Err(err) = Assets::create(b".gitignore", Assets::GITIGNORE, &[]) { + Assets::failed(err, b".gitignore"); + } } match template { @@ -849,7 +835,7 @@ 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(); } @@ -864,41 +850,36 @@ impl InitCommand { } } - 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!\");"); - // suppress + if let Err(err) = + Assets::create_new(&fields.entry_point, b"console.log(\"Hello via Bun!\");") + { + Assets::failed(err, &fields.entry_point); + } } if steps.write_tsconfig { - 'brk: { - let extname = bun_paths::extension(&fields.entry_point); - let loader = options::DEFAULT_LOADERS - .get(extname) - .copied() - .unwrap_or(bun_ast::Loader::Ts); - let filename: &[u8] = if loader.is_type_script() { - b"tsconfig.json" - } else { - b"jsconfig.json" - }; - if Assets::create_full( - Assets::TSCONFIG_JSON, - filename, - " (for editor autocomplete)", - &[], - ) - .is_err() - { - break 'brk; - } + let extname = bun_paths::extension(&fields.entry_point); + let loader = options::DEFAULT_LOADERS + .get(extname) + .copied() + .unwrap_or(bun_ast::Loader::Ts); + let filename: &[u8] = if loader.is_type_script() { + b"tsconfig.json" + } else { + b"jsconfig.json" + }; + if let Err(err) = Assets::create_full( + Assets::TSCONFIG_JSON, + filename, + " (for editor autocomplete)", + &[], + ) { + Assets::failed(err, filename); } } if steps.write_readme { - let _ = Assets::create( + if let Err(err) = Assets::create( b"README.md", Assets::README_MD, &[ @@ -906,8 +887,9 @@ impl InitCommand { (b"bunVersion", Environment::VERSION_STRING.as_bytes()), (b"entryPoint", fields.entry_point.as_slice()), ], - ); - // suppressed + ) { + Assets::failed(err, b"README.md"); + } } if !fields.entry_point.is_empty() && !did_load_package_json { @@ -954,82 +936,43 @@ 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, replacing anything that already exists. With + /// `args`, `asset` is a template and `{[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), but fails with `EEXIST` instead of + /// replacing an existing file, and 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); } } - 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::TRUNC; + 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!( " + {}{}", @@ -1040,38 +983,30 @@ 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, - )?; - - if is_template { - let buf = bun_fmt::substitute_named(contents, args); - file.write_all(&buf)?; - } else { - file.write_all(contents)?; + /// Open `filename` with `flags` and write all of `contents` to it. When the + /// write fails the file is removed again, so that nothing half-written is + /// left under its name and a rerun (which skips existing files) 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)) + } - 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); } } @@ -1463,6 +1398,15 @@ impl Template { } fn create_agent_rule() { + /// Whether the rule was created. One that already exists is left alone. + fn create_rule(path: &[u8], contents: &[u8]) -> bool { + match Assets::create_new(path, contents) { + Ok(()) => true, + Err(err) if err.get_errno() == bun_sys::E::EEXIST => false, + Err(err) => Assets::failed(err, path), + } + } + let mut create_claude_md = Self::is_claude_code_installed() // Never overwrite CLAUDE.md && !exists(b"CLAUDE.md"); @@ -1475,28 +1419,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 { + // CLAUDE.md appeared in the meantime: install the plain cursor rule instead. + create_claude_md = false; + create_rule(template_file.path, template_file.contents); } #[cfg(not(windows))] @@ -1506,7 +1433,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"); @@ -1541,11 +1468,7 @@ impl Template { None => 0, }; - let _ = Assets::create_new( - ZStr::from_static(b"CLAUDE.md\0"), - // SAFETY: literal is NUL-terminated - &Self::AGENT_RULE[end_of_frontmatter..], - ); + create_rule(b"CLAUDE.md", &Self::AGENT_RULE[end_of_frontmatter..]); } } @@ -1629,7 +1552,7 @@ impl Template { let contents = file.contents; let result = if path == b"README.md" { - Assets::create_with_contents( + Assets::create( b"README.md", contents, &[ @@ -1638,28 +1561,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); } } } diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 737c955cee53..cf63c1721906 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -502,3 +502,68 @@ 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 <512-byte blocks>` 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", () => { + async function init(cwd: string, fileSizeLimitBlocks?: number) { + const cmd = + fileSizeLimitBlocks === undefined + ? [bunExe(), "init", "-y"] + : ["sh", "-c", `ulimit -f ${fileSizeLimitBlocks} && exec "$@"`, "sh", bunExe(), "init", "-y"]; + await using proc = Bun.spawn({ cmd, 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 }; + } + + function files(dir: string) { + return Object.fromEntries(readdirSync(dir).map(name => [name, fs.statSync(path.join(dir, name)).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), 0); + expect(stderr).toContain("EFBIG"); + expect(stderr).toContain("failed to write package.json"); + expect(files(String(dir))).toEqual({}); + expect(exitCode).toBe(1); + }); + + test.concurrent( + "template file: reports the error, removes the partial file, exits 1, and a rerun completes the scaffold", + async () => { + using dir = tempDir("bun-init-enospc-template", {}); + // 512 bytes per file: package.json, .gitignore and index.ts fit, + // tsconfig.json (735 bytes) does not. + { + const { stdout, stderr, exitCode } = await init(String(dir), 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. package.json + // already lists the dependencies, so no `bun install` runs. + { + 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); + } + }, + ); +}); From f23167e9a4394d767b592720361db85442544a27 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:04:13 +0000 Subject: [PATCH 2/9] test: fail the agent rule instead of tsconfig.json so the case holds where sh counts ulimit -f in KiB (macOS) --- test/cli/init/init.test.ts | 45 ++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index cf63c1721906..e10b301fdced 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -505,21 +505,32 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; // 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 <512-byte blocks>` a write past the limit fails with -// EFBIG, which takes the same path as ENOSPC. +// so under `ulimit -f N` a write past the limit fails with EFBIG, which takes +// the same path as ENOSPC. N is in 512-byte blocks, or KiB with macOS /bin/sh. describe.skipIf(isWindows)("bun init when a file cannot be written", () => { + // The Cursor rule (.cursor/rules/*.mdc, > 2 KiB) is the one template file + // larger than 1 KiB, so it is the file that `ulimit -f 1` stops everywhere. + const env = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "0", CLAUDE_CODE_AGENT_RULE_DISABLED: "1", CURSOR_TRACE_ID: "1" }; + const rule = ".cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc"; + async function init(cwd: string, fileSizeLimitBlocks?: number) { const cmd = fileSizeLimitBlocks === undefined ? [bunExe(), "init", "-y"] : ["sh", "-c", `ulimit -f ${fileSizeLimitBlocks} && exec "$@"`, "sh", bunExe(), "init", "-y"]; - await using proc = Bun.spawn({ cmd, cwd, env: initEnv, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + await using proc = Bun.spawn({ cmd, cwd, env, 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).map(name => [name, fs.statSync(path.join(dir, name)).size])); + 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 () => { @@ -536,19 +547,15 @@ describe.skipIf(isWindows)("bun init when a file cannot be written", () => { "template file: reports the error, removes the partial file, exits 1, and a rerun completes the scaffold", async () => { using dir = tempDir("bun-init-enospc-template", {}); - // 512 bytes per file: package.json, .gitignore and index.ts fit, - // tsconfig.json (735 bytes) does not. + // package.json and .gitignore fit under the limit and are written first; + // the rule comes next and does not fit. { const { stdout, stderr, exitCode } = await init(String(dir), 1); expect(stderr).toContain("EFBIG"); - expect(stderr).toContain("failed to write tsconfig.json"); - expect(stdout).not.toContain("tsconfig.json"); + expect(stderr).toContain("failed to write " + rule); + expect(stdout).not.toContain(rule); const written = files(String(dir)); - expect(written).toEqual({ - ".gitignore": expect.any(Number), - "index.ts": expect.any(Number), - "package.json": expect.any(Number), - }); + expect(written).toEqual({ ".gitignore": expect.any(Number), "package.json": expect.any(Number) }); expect(Object.values(written)).not.toContain(0); expect(exitCode).toBe(1); } @@ -557,10 +564,16 @@ describe.skipIf(isWindows)("bun init when a file cannot be written", () => { { 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"); + for (const name of [rule, "index.ts", "tsconfig.json", "README.md"]) { + expect(stdout).toContain("+ " + name); + } const written = files(String(dir)); - expect(written).toMatchObject({ "tsconfig.json": expect.any(Number), "README.md": expect.any(Number) }); + expect(written).toMatchObject({ + [rule]: expect.any(Number), + "index.ts": expect.any(Number), + "tsconfig.json": expect.any(Number), + "README.md": expect.any(Number), + }); expect(Object.values(written)).not.toContain(0); expect(exitCode).toBe(0); } From 3bd33cfe1d5eda56958f8fe85ebd6448dd1c308a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:08:21 +0000 Subject: [PATCH 3/9] init: shorten the Assets doc comments --- src/runtime/cli/init_command.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index 0a1eebea3e76..c494e36a266f 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -936,14 +936,12 @@ 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 an asset file, replacing anything that already exists. With - /// `args`, `asset` is a template and `{[name]s}` placeholders are substituted. + /// Create or replace an asset file. With `args`, `{[name]s}` placeholders in `asset` are substituted. fn create(filename: &[u8], asset: &[u8], args: &[(&[u8], &[u8])]) -> bun_sys::Result<()> { Self::create_full(asset, filename, "", args) } - /// Like [`create`](Self::create), but fails with `EEXIST` instead of - /// replacing an existing file, and creates missing parent directories. + /// Like [`create`](Self::create), but fails with `EEXIST` for an existing file; creates parent dirs. fn create_new(filename: &[u8], contents: &[u8]) -> bun_sys::Result<()> { if let Some(dir) = bun_core::dirname(filename) { if !dir.is_empty() && dir != b"." { @@ -983,9 +981,7 @@ impl Assets { Ok(()) } - /// Open `filename` with `flags` and write all of `contents` to it. When the - /// write fails the file is removed again, so that nothing half-written is - /// left under its name and a rerun (which skips existing files) creates it. + /// A file whose write fails is removed again, so a rerun (which skips existing files) 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))?; From 2d5ef4d8e5a3b9043032db262466540485ea9dda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:56:19 +0000 Subject: [PATCH 4/9] ci: retrigger From 39dc1604139d6a70bcba60aef7738b1713703ea1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:22:55 +0000 Subject: [PATCH 5/9] init: never open an existing scaffold file for writing; EEXIST means skip All template files are now created with O_EXCL. A file that already exists (README.md, .gitignore, tsconfig.json, the agent rules, the React template files) is left as it is and is never a reason to exit 1; only a file that bun init creates itself and cannot fill is. The React README.md used O_TRUNC because it needs placeholder substitution and so was the one template file that overwrote an existing copy; it is now skipped like the others. --- src/runtime/cli/init_command.rs | 78 +++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index c494e36a266f..206c1ceef934 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -824,9 +824,10 @@ impl InitCommand { } if steps.write_gitignore { - if let Err(err) = Assets::create(b".gitignore", Assets::GITIGNORE, &[]) { - Assets::failed(err, b".gitignore"); - } + Assets::check( + Assets::create(b".gitignore", Assets::GITIGNORE, &[]), + b".gitignore", + ); } match template { @@ -850,11 +851,13 @@ impl InitCommand { } } - if let Err(err) = - Assets::create_new(&fields.entry_point, b"console.log(\"Hello via Bun!\");") - { - Assets::failed(err, &fields.entry_point); - } + Assets::check( + Assets::create_new( + &fields.entry_point, + b"console.log(\"Hello via Bun!\");", + ), + &fields.entry_point, + ); } if steps.write_tsconfig { @@ -868,28 +871,30 @@ impl InitCommand { } else { b"jsconfig.json" }; - if let Err(err) = Assets::create_full( - Assets::TSCONFIG_JSON, + Assets::check( + Assets::create_full( + Assets::TSCONFIG_JSON, + filename, + " (for editor autocomplete)", + &[], + ), filename, - " (for editor autocomplete)", - &[], - ) { - Assets::failed(err, filename); - } + ); } if steps.write_readme { - if let Err(err) = 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()), - ], - ) { - Assets::failed(err, b"README.md"); - } + ); } if !fields.entry_point.is_empty() && !did_load_package_json { @@ -936,12 +941,12 @@ 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 or replace an asset file. With `args`, `{[name]s}` placeholders in `asset` are substituted. + /// 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) } - /// Like [`create`](Self::create), but fails with `EEXIST` for an existing file; creates parent dirs. + /// 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"." { @@ -966,7 +971,7 @@ impl Assets { message_suffix: &'static str, args: &[(&[u8], &[u8])], ) -> bun_sys::Result<()> { - let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; + 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 { @@ -995,6 +1000,16 @@ impl Assets { result.map_err(|err| err.with_path(filename)) } + /// Whether the file was created. One that already exists is left as it is; any other + /// failure is reported and 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), + } + } + /// Report a scaffold file that could not be written and exit(1). fn failed(err: bun_sys::Error, filename: &[u8]) -> ! { Output::err( @@ -1394,13 +1409,8 @@ impl Template { } fn create_agent_rule() { - /// Whether the rule was created. One that already exists is left alone. fn create_rule(path: &[u8], contents: &[u8]) -> bool { - match Assets::create_new(path, contents) { - Ok(()) => true, - Err(err) if err.get_errno() == bun_sys::E::EEXIST => false, - Err(err) => Assets::failed(err, path), - } + Assets::check(Assets::create_new(path, contents), path) } let mut create_claude_md = Self::is_claude_code_installed() From 8fe6417a4a0b19b8862e57ad8cda80257bbd96f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:25:03 +0000 Subject: [PATCH 6/9] init: one-line doc comment for Assets::check --- src/runtime/cli/init_command.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index 206c1ceef934..e3af6af1172b 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -1000,8 +1000,7 @@ impl Assets { result.map_err(|err| err.with_path(filename)) } - /// Whether the file was created. One that already exists is left as it is; any other - /// failure is reported and exits. + /// 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, From 4a7014a25395618295d6a0e16bcfbd6a4616e469 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:42:31 +0000 Subject: [PATCH 7/9] init: only remove a package.json that init created itself An existing package.json that could not be read or is not a JSON object is regenerated in place as before (O_TRUNC, no unlink on failure); only a package.json created by this run (O_EXCL) is removed when its write fails. --- src/runtime/cli/init_command.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index e3af6af1172b..e359d82709d6 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -401,6 +401,7 @@ impl InitCommand { let mut package_json_file: Option = bun_sys::File::openat(destination_dir, b"package.json", bun_sys::O::RDWR, 0).ok(); + let package_json_existed = package_json_file.is_some(); let mut package_json_contents: MutableString = MutableString::init_empty(); bun_ast::initialize_store(); // Arena for JSON parse / Expr building. @@ -812,9 +813,18 @@ impl InitCommand { i64::try_from(written.len()).expect("int cast"), ) }), + // Exists but could not be read or is not a JSON object: start it over in place. + None if package_json_existed => bun_sys::File::openat( + Fd::cwd(), + b"package.json", + bun_sys::O::WRONLY | bun_sys::O::TRUNC, + 0, + ) + .and_then(|file| file.write_all(written)) + .map_err(|err| err.with_path(b"package.json")), None => Assets::write_new_file( b"package.json", - bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::EXCL, written, ), }; @@ -986,7 +996,7 @@ impl Assets { Ok(()) } - /// A file whose write fails is removed again, so a rerun (which skips existing files) creates it. + /// `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))?; From 02e4b0712e9d0c3de7ed524bf78744c3cf09fcd9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:21:46 +0000 Subject: [PATCH 8/9] init: create the entry stub only where nothing loads for the package yet Over an existing package.json, bun init wrote the "Hello via Bun!" stub at the literal `module`/`main` string whenever that exact path did not exist. With `"main": "./index"` beside index.js, or `"main": "./index.js"` beside index.ts, the stub then ran in place of the module the package resolved to before. It was also written under --minimal, and into paths that are not scripts (data.json, addon.node). The stub gate now asks, for an entry that comes from package.json: - is it a JavaScript or TypeScript file name, and - does `bun .` fail to find a module for the package (the same resolve that `bun .` makes, only reached when the named file is missing). --minimal never creates the stub. A stub that cannot be created at a path named by package.json does not fail init; init's own index.ts still does. --- docs/runtime/templating/init.mdx | 2 +- src/runtime/cli/init_command.rs | 68 ++++++++++++---- test/cli/init/init.test.ts | 128 +++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 14 deletions(-) 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 e1f0db186142..8a6d33a52c93 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` @@ -466,6 +467,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( @@ -501,6 +504,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); } } @@ -868,23 +880,21 @@ impl InitCommand { 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); - } - } - - Assets::check( - Assets::create_new( - &fields.entry_point, - b"console.log(\"Hello via Bun!\");\n", - ), + 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); + } } if steps.write_tsconfig { @@ -1875,6 +1885,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 20cc56c8f8a2..ab50af1f6c55 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -706,3 +706,131 @@ describe.skipIf(isWindows)("bun init when a file cannot be written", () => { }, ); }); + +// `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 }); + }); + }); + + // 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, + })), + ); + }); +}); From 2ef719da042ef8d64de9e1f59535acc0865534af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:09:06 +0000 Subject: [PATCH 9/9] init: keep agent rules best-effort and report the real cause for package.json - The Cursor rule and CLAUDE.md are extras for a detected editor. A rule that cannot be written is skipped again, as on main, instead of stopping init before index.ts, tsconfig.json and README.md. A failed write still leaves no partial file. - Whether package.json exists comes from the open error, not from whether the open succeeded. A package.json that cannot be opened is rewritten in place and reports its own errno (EACCES, EISDIR) instead of EEXIST. A package.json that is a symlink to a missing file is written through the link, as on main. - A parent directory that cannot be created is the error that is reported, not the ENOENT of the open that follows. --- src/runtime/cli/init_command.rs | 44 ++++--- test/cli/init/init.test.ts | 217 +++++++++++++++++++++----------- 2 files changed, 174 insertions(+), 87 deletions(-) diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index 8a6d33a52c93..7f955a72a4b6 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -400,9 +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_existed = package_json_file.is_some(); + 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. @@ -842,20 +844,19 @@ impl InitCommand { i64::try_from(written.len()).expect("int cast"), ) }), - // Exists but could not be read or is not a JSON object: start it over in place. - None if package_json_existed => bun_sys::File::openat( - Fd::cwd(), - b"package.json", - bun_sys::O::WRONLY | bun_sys::O::TRUNC, - 0, - ) - .and_then(|file| file.write_all(written)) - .map_err(|err| err.with_path(b"package.json")), - None => Assets::write_new_file( + 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"); @@ -982,7 +983,7 @@ impl Assets { 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)?; } } Self::write_new_file( @@ -1032,6 +1033,14 @@ impl Assets { result.map_err(|err| err.with_path(filename)) } + /// 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 { @@ -1440,8 +1449,9 @@ 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::check(Assets::create_new(path, contents), path) + Assets::create_new(path, contents).is_ok() } let mut create_claude_md = Self::is_claude_code_installed() @@ -1458,7 +1468,7 @@ impl Template { }; let created = create_rule(asset_path, template_file.contents); if !created && create_claude_md { - // CLAUDE.md appeared in the meantime: install the plain cursor rule instead. + // 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); } diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index ab50af1f6c55..c40cecae62dd 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -632,19 +632,42 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; // 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. N is in 512-byte blocks, or KiB with macOS /bin/sh. +// the same path as ENOSPC. describe.skipIf(isWindows)("bun init when a file cannot be written", () => { - // The Cursor rule (.cursor/rules/*.mdc, > 2 KiB) is the one template file - // larger than 1 KiB, so it is the file that `ulimit -f 1` stops everywhere. - const env = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "0", CLAUDE_CODE_AGENT_RULE_DISABLED: "1", CURSOR_TRACE_ID: "1" }; - const rule = ".cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc"; + // `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, fileSizeLimitBlocks?: number) { + async function init(cwd: string, options: { limit?: number; env?: Record } = {}) { const cmd = - fileSizeLimitBlocks === undefined + options.limit === undefined ? [bunExe(), "init", "-y"] - : ["sh", "-c", `ulimit -f ${fileSizeLimitBlocks} && exec "$@"`, "sh", bunExe(), "init", "-y"]; - await using proc = Bun.spawn({ cmd, cwd, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + : ["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 }; } @@ -662,48 +685,94 @@ describe.skipIf(isWindows)("bun init when a file cannot be written", () => { 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), 0); + 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); }); - test.concurrent( - "template file: reports the error, removes the partial file, exits 1, and a rerun completes the scaffold", + // 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-template", {}); - // package.json and .gitignore fit under the limit and are written first; - // the rule comes next and does not fit. + using dir = tempDir("bun-init-enospc-tsconfig", { "package.json": withDependencies }); { - const { stdout, stderr, exitCode } = await init(String(dir), 1); + const { stdout, stderr, exitCode } = await init(String(dir), { limit: 1 }); expect(stderr).toContain("EFBIG"); - expect(stderr).toContain("failed to write " + rule); - expect(stdout).not.toContain(rule); + 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), "package.json": expect.any(Number) }); + 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. package.json - // already lists the dependencies, so no `bun install` runs. + // With space available again the missing files are created. { const { stdout, stderr, exitCode } = await init(String(dir)); expect(stderr).not.toContain("failed to write"); - for (const name of [rule, "index.ts", "tsconfig.json", "README.md"]) { - expect(stdout).toContain("+ " + name); - } + expect(stdout).toContain("+ tsconfig.json"); + expect(stdout).toContain("+ README.md"); const written = files(String(dir)); - expect(written).toMatchObject({ - [rule]: expect.any(Number), - "index.ts": expect.any(Number), - "tsconfig.json": expect.any(Number), - "README.md": expect.any(Number), - }); + 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, ); }); @@ -759,21 +828,25 @@ describe("bun init over a package.json that names an entry point", () => { ["-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 }); - }); + ] 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 @@ -808,29 +881,33 @@ describe("bun init over a package.json that names an entry point", () => { 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, - })), - ); - }); + 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, + ); });