Add interactive prompt for bun init in non-empty directories

When running `bun init` in a non-empty directory in TTY mode, users are now presented with an interactive prompt asking whether to:
1. Create in a new subdirectory (default)
2. Use the current directory (may overwrite files)
3. Cancel

This prevents accidental file creation in wrong directories and provides a better UX, similar to tools like `create-next-app`.

The prompt is only shown when:
- No folder was explicitly specified as an argument
- stdin is a TTY (not piped input)
- Not using auto-yes flags (-y, --react, etc.)
- The directory is not empty (ignores .DS_Store and Thumbs.db)

Fixes #24555

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2025-11-11 03:32:33 +00:00
parent 0a307ed880
commit b4493db088
2 changed files with 173 additions and 0 deletions

View File

@@ -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("<r><yellow>⚠<r> 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 => "<cyan>Create in a new subdirectory<r>",
.use_current => "<yellow>Use current directory (may overwrite files)<r>",
.cancel => "<red>Cancel<r>",
};
}
});
switch (selected) {
.create_subdirectory => {
const folder_name = prompt(
alloc,
"<r><cyan>subdirectory name<r> ",
"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("<r><d>Cancelled.<r>", .{});
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("<r><d>Cancelled.<r>", .{});
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) });

View File

@@ -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);
});