mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
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:
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user