diff --git a/src/cli/init_command.zig b/src/cli/init_command.zig index 4f88e56bbf..14195faa07 100644 --- a/src/cli/init_command.zig +++ b/src/cli/init_command.zig @@ -339,6 +339,72 @@ pub const InitCommand = struct { private: bool = true, }; + fn isDirectoryEmpty() bool { + var dir = std.fs.cwd().openDir(".", .{ .iterate = true }) catch return true; + defer dir.close(); + var it = bun.DirIterator.iterate(.fromStdDir(dir), .u8); + while (it.next().unwrap() catch return true) |entry| { + const name = entry.name.slice(); + // Ignore common hidden files that don't count as "project files" + if (strings.eqlComptime(name, ".") or + strings.eqlComptime(name, "..") or + strings.eqlComptime(name, ".DS_Store") or + strings.eqlComptime(name, "Thumbs.db")) + { + continue; + } + return false; + } + return true; + } + + fn promptForNonEmptyDirectory(alloc: std.mem.Allocator) !?[]const u8 { + Output.prettyln(" The current directory is not empty.", .{}); + Output.flush(); + + const selected = try radio("What would you like to do?", enum { + create_subdirectory, + use_current, + cancel, + + pub const default: @This() = .create_subdirectory; + + pub fn fmt(self: @This()) []const u8 { + return switch (self) { + .create_subdirectory => "Create in a new subdirectory", + .use_current => "Use current directory (may overwrite files)", + .cancel => "Cancel", + }; + } + }); + + switch (selected) { + .create_subdirectory => { + const folder_name = prompt( + alloc, + "subdirectory name ", + "my-app", + ) catch |err| { + if (err == error.EndOfStream) return null; + return err; + }; + + if (folder_name.len == 0) { + Output.prettyErrorln("Subdirectory name cannot be empty", .{}); + return null; + } + + return folder_name; + }, + .use_current => { + return ""; + }, + .cancel => { + return null; + }, + } + } + pub fn exec(alloc: std.mem.Allocator, init_args: [][:0]const u8) !void { // --minimal is a special preset to create only empty package.json + tsconfig.json var minimal = false; @@ -387,6 +453,34 @@ pub const InitCommand = struct { } } + // Check if directory is non-empty and we're in a TTY environment + // Only prompt if no folder was explicitly specified and stdin is a TTY + const stdin_is_tty = std.posix.isatty(bun.FD.stdin().native()); + + if (initialize_in_folder == null and !auto_yes and Output.enable_ansi_colors_stderr and stdin_is_tty) { + if (!isDirectoryEmpty()) { + const result = promptForNonEmptyDirectory(alloc) catch |err| { + if (err == error.EndOfStream) { + Output.prettyln("Cancelled.", .{}); + Global.exit(0); + } + return err; + }; + + if (result) |folder| { + if (folder.len > 0) { + // User wants to create a subdirectory + initialize_in_folder = folder; + } + // else: folder.len == 0 means use current directory + } else { + // User cancelled + Output.prettyln("Cancelled.", .{}); + Global.exit(0); + } + } + } + if (initialize_in_folder) |ifdir| { std.fs.cwd().makePath(ifdir) catch |err| { Output.prettyErrorln("Failed to create directory {s}: {s}", .{ ifdir, @errorName(err) }); diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 1cabbaf20f..0eba88e062 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -295,4 +295,83 @@ import path from "path"; expect(fs.existsSync(path.join(temp, "src/components"))).toBe(true); expect(fs.existsSync(path.join(temp, "src/components/ui"))).toBe(true); }, 30_000); + + test("bun init in non-empty directory with --yes skips prompt", async () => { + const temp = tempDirWithFiles("bun-init-non-empty-auto-yes", { + "existing-file.txt": "I exist", + }); + + const { exited } = Bun.spawn({ + cmd: [bunExe(), "init", "-y"], + cwd: temp, + stdio: ["ignore", "inherit", "inherit"], + env: bunEnv, + }); + + expect(await exited).toBe(0); + + // With -y flag, should proceed in current directory without prompting + expect(fs.existsSync(path.join(temp, "existing-file.txt"))).toBe(true); + expect(fs.existsSync(path.join(temp, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(temp, "index.ts"))).toBe(true); + }, 30_000); + + test("bun init --react in non-empty directory skips prompt (auto-yes)", async () => { + const temp = tempDirWithFiles("bun-init-react-non-empty", { + "existing-file.txt": "I exist", + }); + + const { exited } = Bun.spawn({ + cmd: [bunExe(), "init", "--react"], + cwd: temp, + stdio: ["ignore", "inherit", "inherit"], + env: bunEnv, + }); + + expect(await exited).toBe(0); + + // --react flag implies auto-yes, should proceed in current directory + expect(fs.existsSync(path.join(temp, "existing-file.txt"))).toBe(true); + expect(fs.existsSync(path.join(temp, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(temp, "src"))).toBe(true); + }, 30_000); + + test("bun init with explicit folder ignores non-empty directory check", async () => { + const temp = tempDirWithFiles("bun-init-explicit-folder", { + "existing-file.txt": "I exist", + }); + + const { exited } = Bun.spawn({ + cmd: [bunExe(), "init", "-y", "new-folder"], + cwd: temp, + stdio: ["ignore", "inherit", "inherit"], + env: bunEnv, + }); + + expect(await exited).toBe(0); + + // Should create in specified folder without prompting + expect(fs.existsSync(path.join(temp, "existing-file.txt"))).toBe(true); + expect(fs.existsSync(path.join(temp, "new-folder"))).toBe(true); + expect(fs.existsSync(path.join(temp, "new-folder/package.json"))).toBe(true); + }, 30_000); + + test("bun init in directory with only .DS_Store is considered empty", async () => { + const temp = tempDirWithFiles("bun-init-only-ds-store", { + ".DS_Store": "macOS metadata", + }); + + const { exited } = Bun.spawn({ + cmd: [bunExe(), "init", "-y"], + cwd: temp, + stdio: ["ignore", "inherit", "inherit"], + env: bunEnv, + }); + + expect(await exited).toBe(0); + + // Should proceed without prompting since .DS_Store is ignored + expect(fs.existsSync(path.join(temp, "package.json"))).toBe(true); + expect(fs.existsSync(path.join(temp, "index.ts"))).toBe(true); + }, 30_000); });