aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Jarred Sumner <jarred@jarredsumner.com> 2021-10-14 16:14:48 -0700
committerGravatar Jarred Sumner <jarred@jarredsumner.com> 2021-10-14 16:14:48 -0700
commitfe24b96d61b372af49a346db595d8261ecfdfe5f (patch)
tree571bee2ffeafe2e4c6fd999f4e3f182a75bc6d9a
parent4b6d8a152f2d298bfd8333cdc1534eb540ffbb79 (diff)
downloadbun-fe24b96d61b372af49a346db595d8261ecfdfe5f.tar.gz
bun-fe24b96d61b372af49a346db595d8261ecfdfe5f.tar.zst
bun-fe24b96d61b372af49a346db595d8261ecfdfe5f.zip
Support passing an absolute path
-rw-r--r--examples/README.md109
-rw-r--r--examples/bun-create.md65
-rw-r--r--misctools/tgz.zig2
-rw-r--r--src/cli/create_command.zig308
4 files changed, 313 insertions, 171 deletions
diff --git a/examples/README.md b/examples/README.md
index e69de29bb..5b857090a 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -0,0 +1,109 @@
+# bun create
+
+`bun create` is a fast way to create a new project from a template. At the time of writing, `bun create react app` runs ~14x faster on my local computer than `yarn create react-app app`. `bun create` currently does no caching (though your npm client does)
+
+## Usage
+
+Templates are downloaded from folders inside `examples/` in Bun's GitHub repo. Running `bun create react ./local-path` downloads the `react` folder from `examples/react`.
+
+Create a new Next.js project:
+
+```bash
+bun create next ./app`
+```
+
+Create a new React project:
+
+```bash
+bun create react ./app
+```
+
+To see a list of available templates, run
+
+```bash
+bun create
+```
+
+### Advanced
+
+| Flag | Description |
+| ---------------------- | -------------------------------------- |
+| --npm | Use `npm` for tasks & install |
+| --yarn | Use `yarn` for tasks & install |
+| --pnpm | Use `pnpm` for tasks & install |
+| --force | Overwrite existing files |
+| --no-install | Skip installing `node_modules` & tasks |
+| --no-git | Don't initialize a git repository |
+| ---------------------- | ----------------------------------- |
+
+By default, `bun create` will cancel if there are existing files it would overwrite. You can pass `--force` to disable this behavior.
+
+## Adding a new template
+
+Clone this repository and a new folder in `examples/` with your new template. The `package.json` must have a `name` that starts with `@bun-examples/`. Do not worry about publishing it, that will happen automaticallly after the PR is merged.
+
+Make sure to include a `.gitignore` that includes `node_modules` so that `node_modules` aren't checked in to git when people download the template.
+
+#### Testing your new template
+
+### Config
+
+The `bun-create` section of package.json is automatically removed from the `package.json` on disk. This lets you add create-only steps without waiting for an extra package to install.
+
+There are currently two options:
+
+- `postinstall`
+- `preinstall`
+
+They can be an array of strings or one string. An array of steps will be executed in order.
+
+Here is an example:
+
+```json
+{
+ "name": "@bun-examples/next",
+ "version": "0.0.31",
+ "main": "index.js",
+ "dependencies": {
+ "next": "11.1.2",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "react-is": "^17.0.2"
+ },
+ "devDependencies": {
+ "@types/react": "^17.0.19",
+ "bun-framework-next": "^0.0.0-21",
+ "typescript": "^4.3.5"
+ },
+ "bun-create": {
+ "postinstall": ["bun bun --use next"]
+ }
+}
+```
+
+By default, all commands run inside the environment exposed by the auto-detected npm client. This incurs a significant performance penalty, something like 150ms spent waiting for the npm client to start on each invocation.
+
+Any command that starts with `"bun "` will be run without npm, relying on the first `bun` binary in `$PATH`.
+
+## How `bun create` works
+
+When you run `bun create ${template} ${destination}`, here's what happens:
+
+1. GET `registry.npmjs.org/@bun-examples/${template}/latest` and parse it
+2. GET `registry.npmjs.org/@bun-examples/${template}/-/${template}-${latestVersion}.tgz`
+3. Decompress & extract `${template}-${latestVersion}.tgz` into `${destination}`
+
+ - If there are files that would overwrite, warn and exit unless `--force` is passed
+
+4. Parse the `package.json` (again!), update `name` to be `${basename(destination)}`, remove the `bun-create` section from the `package.json` and save updated `package.json` to disk
+5. Auto-detect the npm client, preferring `pnpm`, `yarn` (v1), and lastly `npm`
+6. Run any tasks defined in `"bun-create": { "preinstall" }` with the npm client
+7. Run `${npmClient} install`
+8. Run any tasks defined in `"bun-create": { "preinstall" }` with the npm client
+9. Run `git init; git add -A .; git commit -am "Initial Commit";`.
+
+ - Rename `gitignore` to `.gitignore`. NPM automatically removes `.gitignore` files from appearing in packages.
+
+10. Done
+
+`../misctools/publish-examples.js` publishes all examples to npm.
diff --git a/examples/bun-create.md b/examples/bun-create.md
deleted file mode 100644
index c74db89e4..000000000
--- a/examples/bun-create.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# `bun create`
-
-## Config
-
-The `bun-create` section of package.json is automatically removed from the final output. This lets you add create-only steps without installing an extra package.
-
-There are currently two options:
-
-- `postinstall`
-- `preinstall`
-
-They can be an array of strings or one string. An array of strings will be executed one after another.
-
-Here are examples:
-
-```
-{
- "name": "@bun-examples/next",
- "version": "0.0.31",
- "main": "index.js",
- "dependencies": {
- "next": "11.1.2",
- "react": "^17.0.2",
- "react-dom": "^17.0.2",
- "react-is": "^17.0.2"
- },
- "devDependencies": {
- "@types/react": "^17.0.19",
- "bun-framework-next": "^0.0.0-21",
- "typescript": "^4.3.5"
- },
- "bun-create": {
- "postinstall": [
- "bun bun --use next"
- ]
- }
-}
-```
-
-By default, all commands run inside the environment exposed by the auto-detected npm client. This incurs a significant performance penalty, something like 150ms wasted on waiting for the npm client to start on each invocation.
-
-Any command that starts with `"bun "` will be run without npm.
-
-## How it works
-
-When you run `bun create ${template} ${destination}`, here's what happens:
-
-1. GET `registry.npmjs.org/@bun-examples/${template}/latest` and parse it
-2. GET `registry.npmjs.org/@bun-examples/${template}/-/${template}-${latestVersion}.tgz`
-3. Decompress & extract `${template}-${latestVersion}.tgz` into `${destination}`
-
- - If there are files that would overwrite, warn and exit unless `--force` is passed
-
-4. Parse the `package.json` (again!), update `name` to be `${basename(destination)}`, remove the `bun-create` section from the `package.json` and save updated `package.json` to disk
-5. Auto-detect the npm client, preferring `pnpm`, `yarn` (v1), and lastly `npm`
-6. Run any tasks defined in `"bun-create": { "preinstall" }` with the npm client
-7. Run `${npmClient} install`
-8. Run any tasks defined in `"bun-create": { "preinstall" }` with the npm client
-9. Run `git init; git add -A .; git commit -am "Initial Commit";`.
-
- - Rename `gitignore` to `.gitignore`. NPM automatically removes `.gitignore` files from appearing in packages.
-
-10. Done
-
-`../misctools/publish-examples.js` publishes all examples to npm.
diff --git a/misctools/tgz.zig b/misctools/tgz.zig
index dc18564bf..5e792d0fb 100644
--- a/misctools/tgz.zig
+++ b/misctools/tgz.zig
@@ -71,5 +71,5 @@ pub fn main() anyerror!void {
tarball_buf_list = std.ArrayListUnmanaged(u8){ .capacity = file_buf.len, .items = file_buf };
}
- try Archive.extractToDisk(file_buf, folder, 1, false);
+ try Archive.extractToDisk(file_buf, folder, null, 1, false);
}
diff --git a/src/cli/create_command.zig b/src/cli/create_command.zig
index a5740a331..d501e0473 100644
--- a/src/cli/create_command.zig
+++ b/src/cli/create_command.zig
@@ -95,18 +95,18 @@ const CreateOptions = struct {
overwrite: bool = false,
skip_git: bool = false,
- pub fn parse(allocator: *std.mem.Allocator) !CreateOptions {
- const params = comptime [_]clap.Param(clap.Help){
- clap.parseParam("--help Print this menu") catch unreachable,
- clap.parseParam("--npm Use npm for tasks & install") catch unreachable,
- clap.parseParam("--yarn Use yarn for tasks & install") catch unreachable,
- clap.parseParam("--pnpm Use pnpm for tasks & install") catch unreachable,
- clap.parseParam("--force Overwrite existing files") catch unreachable,
- clap.parseParam("--no-install Don't install node_modules") catch unreachable,
- clap.parseParam("--no-git Don't create a git repository") catch unreachable,
- clap.parseParam("<POS>... ") catch unreachable,
- };
-
+ const params = [_]clap.Param(clap.Help){
+ clap.parseParam("--help Print this menu") catch unreachable,
+ clap.parseParam("--npm Use npm for tasks & install") catch unreachable,
+ clap.parseParam("--yarn Use yarn for tasks & install") catch unreachable,
+ clap.parseParam("--pnpm Use pnpm for tasks & install") catch unreachable,
+ clap.parseParam("--force Overwrite existing files") catch unreachable,
+ clap.parseParam("--no-install Don't install node_modules") catch unreachable,
+ clap.parseParam("--no-git Don't create a git repository") catch unreachable,
+ clap.parseParam("<POS>... ") catch unreachable,
+ };
+
+ pub fn parse(allocator: *std.mem.Allocator, comptime print_flags_only: bool) !CreateOptions {
var diag = clap.Diagnostic{};
var args = clap.parse(clap.Help, &params, .{ .diagnostic = &diag, .allocator = allocator }) catch |err| {
@@ -115,8 +115,15 @@ const CreateOptions = struct {
return err;
};
- if (args.flag("--help")) {
- clap.help(Output.writer(), &params) catch {};
+ if (args.flag("--help") or comptime print_flags_only) {
+ if (comptime print_flags_only) {
+ clap.help(Output.writer(), params[1..]) catch {};
+ return undefined;
+ }
+
+ Output.prettyln("<r><b>bun create<r> flags:\n", .{});
+ clap.help(Output.writer(), params[1..]) catch {};
+ Output.flush();
std.os.exit(0);
}
@@ -153,9 +160,23 @@ pub const CreateCommand = struct {
var client: HTTPClient = undefined;
var extracting_name_buf: [1024]u8 = undefined;
pub fn exec(ctx: Command.Context, positionals: []const []const u8) !void {
- var create_options = try CreateOptions.parse(ctx.allocator);
+ var create_options = try CreateOptions.parse(ctx.allocator, false);
+
+ var filesystem = try fs.FileSystem.init1(ctx.allocator, null);
+ var env_loader: DotEnv.Loader = brk: {
+ var map = try ctx.allocator.create(DotEnv.Map);
+ map.* = DotEnv.Map.init(ctx.allocator);
+
+ break :brk DotEnv.Loader.init(map, ctx.allocator);
+ };
+
+ env_loader.loadProcess();
+
const template = positionals[0];
const dirname = positionals[1];
+ var filename_writer = filesystem.dirname_store;
+ const destination = try filesystem.dirname_store.append([]const u8, resolve_path.joinAbs(filesystem.top_level_dir, .auto, dirname));
+
var progress = std.Progress{};
var node_ = try progress.start(try std.fmt.bufPrint(&extracting_name_buf, "Loading {s}", .{template}), 0);
@@ -163,7 +184,7 @@ pub const CreateCommand = struct {
var node = node_.start("Downloading", 0);
// alacritty is fast
- if (std.os.getenvZ("ALACRITTY_LOG") != null) {
+ if (env_loader.map.get("ALACRITTY_LOG") != null) {
progress.refresh_rate_ns = std.time.ns_per_ms * 8;
}
@@ -172,115 +193,193 @@ pub const CreateCommand = struct {
progress.refresh();
}
- var filesystem = try fs.FileSystem.init1(ctx.allocator, null);
+ var package_json_contents: MutableString = undefined;
+ var package_json_file: std.fs.File = undefined;
- var tarball_bytes: MutableString = if (!(strings.eqlComptime(std.fs.path.extension(template), ".tgz") or strings.eqlComptime(std.fs.path.extension(template), ".tar.gz")))
- try Example.fetch(ctx, template, &progress, &node)
- else
- Example.fetchFromDisk(ctx, template, &progress, &node) catch |err| {
- node.end();
- progress.refresh();
- Output.prettyErrorln("Error loading package from disk {s}", .{@errorName(err)});
- Output.flush();
- std.os.exit(1);
- };
+ if (!std.fs.path.isAbsolute(template)) {
+ var tarball_bytes: MutableString = try Example.fetch(ctx, template, &progress, &node);
- node.end();
+ node.end();
- node = progress.root.start(try std.fmt.bufPrint(&extracting_name_buf, "Decompressing {s}", .{template}), 0);
- node.setCompletedItems(0);
- node.setEstimatedTotalItems(0);
- node.activate();
- progress.refresh();
+ node = progress.root.start(try std.fmt.bufPrint(&extracting_name_buf, "Decompressing {s}", .{template}), 0);
+ node.setCompletedItems(0);
+ node.setEstimatedTotalItems(0);
+ node.activate();
+ progress.refresh();
- var file_buf = try ctx.allocator.alloc(u8, 16384);
+ var file_buf = try ctx.allocator.alloc(u8, 16384);
- var tarball_buf_list = std.ArrayListUnmanaged(u8){ .capacity = file_buf.len, .items = file_buf };
- var gunzip = try Zlib.ZlibReaderArrayList.init(tarball_bytes.list.items, &tarball_buf_list, ctx.allocator);
- try gunzip.readAll();
- gunzip.deinit();
+ var tarball_buf_list = std.ArrayListUnmanaged(u8){ .capacity = file_buf.len, .items = file_buf };
+ var gunzip = try Zlib.ZlibReaderArrayList.init(tarball_bytes.list.items, &tarball_buf_list, ctx.allocator);
+ try gunzip.readAll();
+ gunzip.deinit();
- node.end();
+ node.end();
- node = progress.root.start(try std.fmt.bufPrint(&extracting_name_buf, "Extracting {s}", .{template}), 0);
- node.setCompletedItems(0);
- node.setEstimatedTotalItems(0);
- node.activate();
- progress.refresh();
+ node = progress.root.start(try std.fmt.bufPrint(&extracting_name_buf, "Extracting {s}", .{template}), 0);
+ node.setCompletedItems(0);
+ node.setEstimatedTotalItems(0);
+ node.activate();
+ progress.refresh();
- var pluckers = [_]Archive.Plucker{
- try Archive.Plucker.init("package.json", 2048, ctx.allocator),
- try Archive.Plucker.init("GETTING_STARTED", 512, ctx.allocator),
- };
+ var pluckers = [_]Archive.Plucker{
+ try Archive.Plucker.init("package.json", 2048, ctx.allocator),
+ try Archive.Plucker.init("GETTING_STARTED", 512, ctx.allocator),
+ };
- var archive_context = Archive.Context{
- .pluckers = &pluckers,
- .overwrite_list = std.StringArrayHashMap(void).init(ctx.allocator),
- };
+ var archive_context = Archive.Context{
+ .pluckers = &pluckers,
+ .overwrite_list = std.StringArrayHashMap(void).init(ctx.allocator),
+ };
- var filename_writer = filesystem.dirname_store;
+ if (!create_options.overwrite) {
+ try Archive.getOverwritingFileList(
+ tarball_buf_list.items,
+ destination,
+ &archive_context,
+ @TypeOf(filesystem.dirname_store),
+ filesystem.dirname_store,
+ 1,
+ );
- const destination = try filesystem.dirname_store.append([]const u8, resolve_path.joinAbs(filesystem.top_level_dir, .auto, dirname));
+ if (archive_context.overwrite_list.count() > 0) {
+ node.end();
+ progress.root.end();
+ progress.refresh();
- if (!create_options.overwrite) {
- try Archive.getOverwritingFileList(
+ // Thank you create-react-app for this copy (and idea)
+ Output.prettyErrorln(
+ "<r><red>error<r><d>: <r>The directory <b><green>{s}<r> contains files that could conflict:",
+ .{
+ std.fs.path.basename(destination),
+ },
+ );
+ for (archive_context.overwrite_list.keys()) |path| {
+ if (strings.endsWith(path, std.fs.path.sep_str)) {
+ Output.prettyErrorln("<r> <cyan>{s}<r>", .{path});
+ } else {
+ Output.prettyErrorln("<r> {s}", .{path});
+ }
+ }
+ Output.flush();
+ std.os.exit(1);
+ }
+ }
+
+ const extracted_file_count = try Archive.extractToDisk(
tarball_buf_list.items,
destination,
&archive_context,
- @TypeOf(filesystem.dirname_store),
- filesystem.dirname_store,
1,
+ false,
);
- if (archive_context.overwrite_list.count() > 0) {
+ var plucker = pluckers[0];
+
+ if (!plucker.found or plucker.fd == 0) {
+ node.end();
+ progress.root.end();
+ Output.prettyErrorln("package.json not found. This package is corrupt. Please try again or file an issue if it keeps happening.", .{});
+ Output.flush();
+ std.os.exit(1);
+ }
+
+ node.end();
+ node = progress.root.start(try std.fmt.bufPrint(&extracting_name_buf, "Updating package.json", .{}), 0);
+
+ node.activate();
+ progress.refresh();
+
+ package_json_contents = plucker.contents;
+ package_json_file = std.fs.File{ .handle = plucker.fd };
+ } else {
+ const template_dir = std.fs.openDirAbsolute(template, .{ .iterate = true }) catch |err| {
+ node.end();
+ progress.root.end();
+ progress.refresh();
+
+ Output.prettyErrorln("<r><red>{s}<r>: opening dir {s}", .{ @errorName(err), template });
+ Output.flush();
+ std.os.exit(1);
+ };
+
+ std.fs.deleteTreeAbsolute(destination) catch {};
+ const destination_dir = std.fs.cwd().makeOpenPath(destination, .{ .iterate = true }) catch |err| {
node.end();
progress.root.end();
progress.refresh();
- // Thank you create-react-app for this copy (and idea)
- Output.prettyErrorln(
- "<r><red>error<r><d>: <r>The directory <b><green>{s}<r> contains files that could conflict:",
- .{
- std.fs.path.basename(destination),
- },
- );
- for (archive_context.overwrite_list.keys()) |path| {
- if (strings.endsWith(path, std.fs.path.sep_str)) {
- Output.prettyErrorln("<r> <cyan>{s}<r>", .{path});
- } else {
- Output.prettyErrorln("<r> {s}", .{path});
- }
- }
+ Output.prettyErrorln("<r><red>{s}<r>: creating dir {s}", .{ @errorName(err), destination });
Output.flush();
std.os.exit(1);
+ };
+
+ var walker = try template_dir.walk(ctx.allocator);
+ defer walker.deinit();
+ while (try walker.next()) |entry| {
+ // TODO: make this not walk these folders entirely
+ // rather than checking each file path.....
+ if (entry.kind != .File or
+ std.mem.indexOf(u8, entry.path, "node_modules") != null or
+ std.mem.indexOf(u8, entry.path, ".git") != null) continue;
+
+ entry.dir.copyFile(entry.basename, destination_dir, entry.path, .{}) catch {
+ if (std.fs.path.dirname(entry.path)) |entry_dirname| {
+ destination_dir.makePath(entry_dirname) catch {};
+ }
+ entry.dir.copyFile(entry.basename, destination_dir, entry.path, .{}) catch |err| {
+ node.end();
+ progress.root.end();
+ progress.refresh();
+
+ Output.prettyErrorln("<r><red>{s}<r>: copying file {s}", .{ @errorName(err), entry.path });
+ Output.flush();
+ std.os.exit(1);
+ };
+ };
}
- }
- const extracted_file_count = try Archive.extractToDisk(
- tarball_buf_list.items,
- destination,
- &archive_context,
- 1,
- false,
- );
+ package_json_file = destination_dir.openFile("package.json", .{ .read = true, .write = true }) catch |err| {
+ node.end();
+ progress.root.end();
+ progress.refresh();
- var plucker = pluckers[0];
+ Output.prettyErrorln("Failed to open package.json due to error <r><red>{s}", .{@errorName(err)});
+ Output.flush();
+ std.os.exit(1);
+ };
+ const stat = package_json_file.stat() catch |err| {
+ node.end();
+ progress.root.end();
+ progress.refresh();
- if (!plucker.found or plucker.fd == 0) {
- node.end();
- progress.root.end();
- Output.prettyErrorln("package.json not found. This package is corrupt. Please try again or file an issue if it keeps happening.", .{});
- Output.flush();
- std.os.exit(1);
- }
+ Output.prettyErrorln("Failed to stat package.json due to error <r><red>{s}", .{@errorName(err)});
+ Output.flush();
+ std.os.exit(1);
+ };
- node.end();
- node = progress.root.start(try std.fmt.bufPrint(&extracting_name_buf, "Updating package.json", .{}), 0);
+ if (stat.kind != .File or stat.size == 0) {
+ node.end();
+ progress.root.end();
+ progress.refresh();
- node.activate();
- progress.refresh();
+ Output.prettyErrorln("package.json must be a file with content", .{});
+ Output.flush();
+ std.os.exit(1);
+ }
+ package_json_contents = try MutableString.init(ctx.allocator, stat.size);
+ package_json_contents.inflate(package_json_file.readAll(package_json_contents.list.items) catch |err| {
+ node.end();
+ progress.root.end();
+ progress.refresh();
- var source = logger.Source.initPathString("package.json", plucker.contents.toOwnedSliceLeaky());
+ Output.prettyErrorln("Error reading package.json: <r><red>{s}", .{@errorName(err)});
+ Output.flush();
+ std.os.exit(1);
+ }) catch unreachable;
+ }
+
+ var source = logger.Source.initPathString("package.json", package_json_contents.list.items);
var package_json_expr = ParseJSON(&source, ctx.log, ctx.allocator) catch |err| {
node.end();
progress.root.end();
@@ -399,7 +498,6 @@ pub const CreateCommand = struct {
node.name = "Saving package.json";
progress.maybeRefresh();
- const package_json_file = std.fs.File{ .handle = plucker.fd };
var package_json_writer = JSPrinter.NewFileWriter(package_json_file);
_ = JSPrinter.printJSON(@TypeOf(package_json_writer), package_json_writer, package_json_expr, &source) catch |err| {
@@ -408,15 +506,6 @@ pub const CreateCommand = struct {
std.os.exit(1);
};
- var env_loader: DotEnv.Loader = brk: {
- var map = try ctx.allocator.create(DotEnv.Map);
- map.* = DotEnv.Map.init(ctx.allocator);
-
- break :brk DotEnv.Loader.init(map, ctx.allocator);
- };
-
- env_loader.loadProcess();
-
const PATH = env_loader.map.get("PATH") orelse "";
var npm_client_: ?NPMClient = null;
@@ -457,6 +546,7 @@ pub const CreateCommand = struct {
node.end();
if (npm_client_) |npm_client| {
+ const start_time = std.time.nanoTimestamp();
var install_args = [_]string{ npm_client.bin, "install" };
Output.printError("\n", .{});
Output.flush();
@@ -468,12 +558,18 @@ pub const CreateCommand = struct {
process.cwd = destination;
defer {
+ Output.printErrorln("\n", .{});
+ Output.printStartEnd(start_time, std.time.nanoTimestamp());
+ Output.prettyError(" <r><d>{s} install<r>\n", .{@tagName(npm_client.tag)});
+ Output.flush();
+
Output.print("\n", .{});
Output.flush();
}
defer process.deinit();
var term = try process.spawnAndWait();
+
_ = process.kill() catch undefined;
} else if (!create_options.skip_install) {
progress.log("Failed to detect npm client. Tried pnpm, yarn, and npm.\n", .{});
@@ -530,7 +626,7 @@ pub const CreateCommand = struct {
Output.printError("\n", .{});
Output.printStartEnd(ctx.start_time, std.time.nanoTimestamp());
- Output.prettyErrorln(" <r><d>bun create {s} <r><d><b>({d} files)<r>", .{ template, extracted_file_count });
+ Output.prettyErrorln(" <r><d>bun create {s}<r>", .{template});
Output.flush();
}
};
@@ -848,6 +944,8 @@ pub const CreateListExamplesCommand = struct {
Example.print(examples);
+ _ = try CreateOptions.parse(ctx.allocator, true);
+
Output.pretty("<d>To add a new template, git clone https://github.com/jarred-sumner/bun, add a new folder to the \"examples\" folder, and submit a PR.<r>", .{});
Output.flush();
}