examples/01-simple/biome.json
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"assist": { "actions": { "source": { "organizeImports": "on" } } }
}
examples/01-simple/json-keys.test.ts
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractKeys, parseArgs } from "./json-keys";
// ============================================================================
// UNIT TESTS
// ============================================================================
describe("extractKeys", () => {
test("extracts flat keys", () => {
expect(extractKeys({ a: 1, b: 2 })).toEqual(["a", "b"]);
});
test("extracts nested keys with dot notation", () => {
expect(extractKeys({ user: { name: "x", age: 30 } })).toEqual([
"user",
"user.name",
"user.age",
]);
});
test("limits depth", () => {
const obj = { a: { b: { c: 1 } } };
expect(extractKeys(obj, 1)).toEqual(["a"]);
expect(extractKeys(obj, 2)).toEqual(["a", "a.b"]);
});
test("filters by regex", () => {
const obj = { userName: 1, userAge: 2, id: 3 };
expect(extractKeys(obj, -1, /^user/)).toEqual(["userName", "userAge"]);
});
test("handles empty object", () => {
expect(extractKeys({})).toEqual([]);
});
test("skips arrays", () => {
expect(extractKeys({ items: [1, 2, 3] })).toEqual(["items"]);
});
test("handles null and primitives", () => {
expect(extractKeys(null)).toEqual([]);
expect(extractKeys("string")).toEqual([]);
expect(extractKeys(123)).toEqual([]);
});
});
describe("parseArgs", () => {
test("parses file argument", () => {
expect(parseArgs(["file.json"])).toMatchObject({ file: "file.json" });
});
test("parses --count flag", () => {
expect(parseArgs(["file.json", "--count"])).toMatchObject({ count: true });
});
test("parses --depth with value", () => {
expect(parseArgs(["--depth", "2", "file.json"])).toMatchObject({
depth: 2,
});
});
test("parses --filter with pattern", () => {
expect(parseArgs(["file.json", "--filter", "^user"])).toMatchObject({
filter: "^user",
});
});
test("parses all flags together", () => {
const result = parseArgs([
"file.json",
"--count",
"--depth",
"3",
"--filter",
"test",
]);
expect(result).toEqual({
file: "file.json",
count: true,
depth: 3,
filter: "test",
});
});
test("returns defaults when no args", () => {
expect(parseArgs([])).toEqual({
file: undefined,
count: false,
depth: -1,
filter: undefined,
});
});
});
// ============================================================================
// CLI INTEGRATION TESTS
// ============================================================================
describe("CLI", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "json-keys-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true });
});
const run = async (...args: string[]) => {
const proc = Bun.spawn(["bun", "./json-keys.ts", ...args], {
cwd: import.meta.dir,
stdout: "pipe",
stderr: "pipe",
});
return {
exitCode: await proc.exited,
stdout: await new Response(proc.stdout).text(),
stderr: await new Response(proc.stderr).text(),
};
};
test("shows usage when no file", async () => {
const { exitCode, stderr } = await run();
expect(exitCode).toBe(1);
expect(stderr).toContain("Usage:");
});
test("errors on missing file", async () => {
const { exitCode, stderr } = await run("nonexistent.json");
expect(exitCode).toBe(1);
expect(stderr).toContain("File not found");
});
test("errors on invalid JSON", async () => {
const testFile = join(tempDir, "invalid.json");
await Bun.write(testFile, "not json");
const { exitCode, stderr } = await run(testFile);
expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid JSON");
});
test("errors on invalid regex", async () => {
const testFile = join(tempDir, "test.json");
await Bun.write(testFile, "{}");
const { exitCode, stderr } = await run(testFile, "--filter", "[invalid");
expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid regex");
});
test("lists keys from valid JSON", async () => {
const testFile = join(tempDir, "test.json");
await Bun.write(testFile, '{"a":1,"b":2}');
const { exitCode, stdout } = await run(testFile);
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("a\nb");
});
test("counts keys with --count", async () => {
const testFile = join(tempDir, "test.json");
await Bun.write(testFile, '{"a":1,"b":2}');
const { exitCode, stdout } = await run(testFile, "--count");
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("2");
});
test("limits depth with --depth", async () => {
const testFile = join(tempDir, "test.json");
await Bun.write(testFile, '{"a":{"b":{"c":1}}}');
const { exitCode, stdout } = await run(testFile, "--depth", "2");
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("a\na.b");
});
test("filters with --filter", async () => {
const testFile = join(tempDir, "test.json");
await Bun.write(testFile, '{"userName":1,"userId":2,"other":3}');
const { exitCode, stdout } = await run(testFile, "--filter", "^user");
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("userName\nuserId");
});
});
examples/01-simple/json-keys.ts
#!/usr/bin/env bun
// Extract keys from JSON files with optional depth limiting and filtering
// ============================================================================
// CONSTANTS
// ============================================================================
const colors = {
red: "\x1b[0;31m",
reset: "\x1b[0m",
} as const;
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function extractKeys(
obj: unknown,
maxDepth = -1,
filter?: RegExp,
prefix = "",
depth = 0,
): string[] {
if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return [];
if (maxDepth !== -1 && depth >= maxDepth) return [];
const keys: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (!filter || filter.test(fullKey)) keys.push(fullKey);
keys.push(...extractKeys(value, maxDepth, filter, fullKey, depth + 1));
}
return keys;
}
export function parseArgs(args: string[]): {
file?: string;
count: boolean;
depth: number;
filter?: string;
} {
const result: {
file?: string;
count: boolean;
depth: number;
filter?: string;
} = {
count: false,
depth: -1,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg) continue;
if (arg === "--count") result.count = true;
else if (arg === "--depth")
result.depth = Number.parseInt(args[++i] || "0", 10);
else if (arg === "--filter") {
const val = args[++i];
if (val) result.filter = val;
} else if (!arg.startsWith("-")) result.file = arg;
}
return result;
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
function error(msg: string): never {
console.error(`${colors.red}Error:${colors.reset} ${msg}`);
process.exit(1);
}
async function main(): Promise<void> {
const { file, count, depth, filter } = parseArgs(Bun.argv.slice(2));
if (!file) {
console.error(
"Usage: json-keys <file.json> [--count] [--depth N] [--filter regex]",
);
process.exit(1);
}
if (!(await Bun.file(file).exists())) {
error(`File not found: ${file}`);
}
let data: unknown;
try {
data = await Bun.file(file).json();
} catch {
error(`Invalid JSON in ${file}`);
}
let regex: RegExp | undefined;
if (filter) {
try {
regex = new RegExp(filter);
} catch {
error(`Invalid regex: ${filter}`);
}
}
const keys = extractKeys(data, depth, regex);
console.log(count ? keys.length : keys.join("\n"));
}
// CLI entry point (only runs when executed directly)
if (import.meta.main) {
main();
}
examples/01-simple/package.json
{
"name": "json-keys",
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "bun test",
"typecheck": "tsc"
},
"type": "module",
"version": "1.0.0",
"devDependencies": {
"@biomejs/biome": "latest",
"@types/bun": "latest",
"typescript": "latest"
}
}
examples/01-simple/tsconfig.json
{
"extends": "../tsconfig.base.json",
"include": ["**/*.ts"]
}
examples/02-medium/biome.json
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
examples/02-medium/package.json
{
"name": "gh-release",
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "bun test",
"typecheck": "tsc"
},
"type": "module",
"version": "1.0.0",
"devDependencies": {
"@biomejs/biome": "latest",
"@types/bun": "latest",
"typescript": "latest"
}
}
examples/02-medium/src/config.ts
// Config file handling for gh-release
import { homedir } from "node:os";
import { join } from "node:path";
// ============================================================================
// TYPES
// ============================================================================
export interface Config {
defaultOutput?: string;
defaultAssetPattern?: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const CONFIG_PATH = join(homedir(), ".gh-release.json");
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function mergeWithDefaults(
config: Config,
overrides: { output?: string; asset?: string },
) {
return {
output: overrides.output ?? config.defaultOutput ?? ".",
assetPattern: overrides.asset ?? config.defaultAssetPattern,
};
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function loadConfig(): Promise<Config> {
if (!(await Bun.file(CONFIG_PATH).exists())) return {};
try {
return await Bun.file(CONFIG_PATH).json();
} catch {
return {};
}
}
examples/02-medium/src/download.ts
// Download and extraction logic for gh-release
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { Asset } from "./github";
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function downloadAndExtract(
asset: Asset,
outputDir: string,
): Promise<number> {
await mkdir(outputDir, { recursive: true });
process.stdout.write(`Downloading ${asset.name}`);
const res = await fetch(asset.browser_download_url);
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
const bytes = await res.bytes();
process.stdout.write(" done\n");
if (asset.name.endsWith(".tar.gz") || asset.name.endsWith(".tgz")) {
process.stdout.write("Extracting...");
const count = await new Bun.Archive(bytes).extract(outputDir);
process.stdout.write(` ${count} files\n`);
return count;
}
await Bun.write(join(outputDir, asset.name), bytes);
return 1;
}
examples/02-medium/src/github.ts
// GitHub API client for gh-release
// ============================================================================
// TYPES
// ============================================================================
export interface Release {
tag_name: string;
assets: Asset[];
}
export interface Asset {
name: string;
browser_download_url: string;
size: number;
}
export interface ParsedTarget {
owner: string;
repo: string;
version?: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const API_BASE = "https://api.github.com";
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function parseTarget(input: string): ParsedTarget | null {
const match = input.match(/^([^/]+)\/([^@]+)(?:@(.+))?$/);
if (!match?.[1] || !match[2]) return null;
const result: ParsedTarget = { owner: match[1], repo: match[2] };
if (match[3]) result.version = match[3];
return result;
}
export function matchAsset(assets: Asset[], pattern?: string): Asset | null {
if (!pattern) return assets[0] ?? null;
const regex = new RegExp(pattern.replace(/\*/g, ".*"));
return assets.find((a) => regex.test(a.name)) ?? null;
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
async function fetchWithRetry(url: string): Promise<Response> {
for (let i = 0; i < MAX_RETRIES; i++) {
const res = await fetch(url, {
headers: { Accept: "application/vnd.github+json" },
});
if (res.ok) return res;
if (res.status === 404) throw new Error("Not found");
if ((res.status === 403 || res.status >= 500) && i < MAX_RETRIES - 1) {
await Bun.sleep(BASE_DELAY_MS * 2 ** i);
continue;
}
throw new Error(`GitHub API error: ${res.status}`);
}
throw new Error("Max retries exceeded");
}
export async function fetchRelease(
owner: string,
repo: string,
version?: string,
): Promise<Release> {
const path = version ? `releases/tags/${version}` : "releases/latest";
return (
await fetchWithRetry(`${API_BASE}/repos/${owner}/${repo}/${path}`)
).json() as Promise<Release>;
}
examples/02-medium/src/index.ts
#!/usr/bin/env bun
// gh-release: Download and extract GitHub release assets
import { loadConfig, mergeWithDefaults } from "./config";
import { downloadAndExtract, formatSize } from "./download";
import { fetchRelease, matchAsset, parseTarget } from "./github";
// ============================================================================
// TYPES
// ============================================================================
interface CLIOptions {
target?: string;
asset?: string;
output: string;
list: boolean;
help: boolean;
version: boolean;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const VERSION = "1.0.0";
const USAGE = `gh-release v${VERSION}
Usage: gh-release <owner/repo[@version]> [--asset <pattern>] [--output <dir>] [--list]
Examples:
gh-release openai/codex # Latest release
gh-release openai/codex@0.87.0 --asset "*.tar.gz" # Specific version`;
const c = { r: "\x1b[0;31m", g: "\x1b[0;32m", b: "\x1b[0;34m", x: "\x1b[0m" };
const log = {
info: (m: string) => console.log(`${c.b}i${c.x} ${m}`),
success: (m: string) => console.log(`${c.g}v${c.x} ${m}`),
error: (m: string) => console.error(`${c.r}x${c.x} ${m}`),
};
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function parseArgs(args: string[]): CLIOptions {
const opts: CLIOptions = {
output: ".",
list: false,
help: false,
version: false,
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (!a) continue;
if (a === "--version") opts.version = true;
else if (a === "--help" || a === "-h") opts.help = true;
else if (a === "--list") opts.list = true;
else if (a === "--asset") {
const val = args[++i];
if (val) opts.asset = val;
} else if (a === "--output") {
const val = args[++i];
if (val) opts.output = val;
} else if (!a.startsWith("-")) opts.target = a;
}
return opts;
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
async function main(): Promise<void> {
const opts = parseArgs(Bun.argv.slice(2));
if (opts.version) {
console.log(VERSION);
return;
}
if (opts.help || !opts.target) {
console.log(USAGE);
if (!opts.help) process.exit(1);
return;
}
const parsed = parseTarget(opts.target);
if (!parsed) {
log.error(`Invalid target: ${opts.target} (expected owner/repo[@version])`);
process.exit(1);
}
const config = await loadConfig();
const overrides: { output?: string; asset?: string } = {
output: opts.output,
};
if (opts.asset) overrides.asset = opts.asset;
const { output, assetPattern } = mergeWithDefaults(config, overrides);
log.info(
`Fetching ${parsed.owner}/${parsed.repo}${parsed.version ? `@${parsed.version}` : " (latest)"}`,
);
const release = await fetchRelease(parsed.owner, parsed.repo, parsed.version);
log.info(`Release: ${release.tag_name}`);
if (opts.list) {
console.log("\nAssets:");
for (const asset of release.assets) {
console.log(` ${asset.name} (${formatSize(asset.size)})`);
}
return;
}
const asset = matchAsset(release.assets, assetPattern);
if (!asset) {
log.error("No matching asset found");
process.exit(1);
}
const count = await downloadAndExtract(asset, output);
log.success(`Extracted ${count} file(s) to ${output}`);
}
// CLI entry point
if (import.meta.main) {
main().catch((e) => {
log.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
}
examples/02-medium/tests/download.test.ts
import { describe, expect, test } from "bun:test";
import { formatSize } from "../src/download";
describe("formatSize", () => {
test.each([
[0, "0 B"],
[512, "512 B"],
[1024, "1.0 KB"],
[1536, "1.5 KB"],
[1048576, "1.0 MB"],
[1572864, "1.5 MB"],
])("formatSize(%d) -> %s", (bytes, expected) => {
expect(formatSize(bytes)).toBe(expected);
});
});
examples/02-medium/tests/github.test.ts
import { describe, expect, test } from "bun:test";
import { type Asset, matchAsset, parseTarget } from "../src/github";
import { parseArgs } from "../src/index";
describe("parseTarget", () => {
test("parses owner/repo", () => {
expect(parseTarget("openai/codex")).toEqual({
owner: "openai",
repo: "codex",
version: undefined,
});
});
test("parses owner/repo@version", () => {
expect(parseTarget("openai/codex@0.87.0")).toEqual({
owner: "openai",
repo: "codex",
version: "0.87.0",
});
});
test("returns null for invalid input", () => {
expect(parseTarget("invalid")).toBeNull();
expect(parseTarget("no-slash")).toBeNull();
expect(parseTarget("")).toBeNull();
});
});
describe("matchAsset", () => {
const linuxAsset: Asset = {
name: "app-linux.tar.gz",
browser_download_url: "url1",
size: 1000,
};
const darwinAsset: Asset = {
name: "app-darwin.tar.gz",
browser_download_url: "url2",
size: 2000,
};
const zipAsset: Asset = {
name: "app.zip",
browser_download_url: "url3",
size: 500,
};
const assets: Asset[] = [linuxAsset, darwinAsset, zipAsset];
test("returns first asset when no pattern", () => {
expect(matchAsset(assets)).toEqual(linuxAsset);
});
test("matches glob pattern", () => {
expect(matchAsset(assets, "*darwin*")).toEqual(darwinAsset);
expect(matchAsset(assets, "*.zip")).toEqual(zipAsset);
});
test("returns null when no match", () => {
expect(matchAsset(assets, "*windows*")).toBeNull();
});
test("returns null for empty array", () => {
expect(matchAsset([])).toBeNull();
});
});
describe("parseArgs", () => {
test("parses target argument", () => {
expect(parseArgs(["openai/codex"])).toMatchObject({
target: "openai/codex",
list: false,
output: ".",
});
});
test("parses all flags", () => {
const result = parseArgs([
"org/repo",
"--asset",
"*.tar.gz",
"--output",
"./bin",
"--list",
]);
expect(result).toMatchObject({
target: "org/repo",
asset: "*.tar.gz",
output: "./bin",
list: true,
});
});
test("handles --help and --version flags", () => {
expect(parseArgs(["--help"])).toMatchObject({ help: true });
expect(parseArgs(["-h"])).toMatchObject({ help: true });
expect(parseArgs(["--version"])).toMatchObject({ version: true });
});
test("defaults when no arguments", () => {
expect(parseArgs([])).toEqual({
output: ".",
list: false,
help: false,
version: false,
});
});
});
examples/02-medium/tsconfig.json
{
"extends": "../tsconfig.base.json",
"include": ["**/*.ts"]
}
examples/03-complex/biome.json
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"assist": { "actions": { "source": { "organizeImports": "on" } } }
}
examples/03-complex/package.json
{
"name": "env-sync",
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"test": "bun test",
"typecheck": "tsc"
},
"type": "module",
"version": "1.0.0",
"devDependencies": {
"@biomejs/biome": "latest",
"@types/bun": "latest",
"typescript": "latest"
}
}
examples/03-complex/src/commands/diff.ts
// Show differences between local .env and stored version
import { resolve } from "node:path";
import type { Log } from "../index";
import { projectExists } from "../lib/config";
import { promptPassword } from "../lib/prompt";
import { hasStoredEnv, loadEncryptedEnv } from "../lib/storage";
// ============================================================================
// CONSTANTS
// ============================================================================
const ENV_FILE = ".env";
const c = { r: "\x1b[0;31m", g: "\x1b[0;32m", y: "\x1b[1;33m", x: "\x1b[0m" };
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function parseEnvFile(content: string): Map<string, string> {
const vars = new Map<string, string>();
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx > 0) vars.set(trimmed.slice(0, idx), trimmed.slice(idx + 1));
}
return vars;
}
export function computeDiff(
local: Map<string, string>,
stored: Map<string, string>,
): { added: string[]; removed: string[]; changed: string[] } {
const added: string[] = [];
const removed: string[] = [];
const changed: string[] = [];
for (const key of local.keys()) {
if (!stored.has(key)) added.push(key);
else if (local.get(key) !== stored.get(key)) changed.push(key);
}
for (const key of stored.keys()) {
if (!local.has(key)) removed.push(key);
}
return { added, removed, changed };
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function diff(log: Log): Promise<number> {
const projectPath = resolve(process.cwd());
if (!(await projectExists(projectPath))) {
log.error("Project not initialized. Run 'env-sync init' first.");
return 1;
}
if (!(await hasStoredEnv(projectPath))) {
log.error("No stored .env found. Run 'env-sync push' first.");
return 1;
}
if (!(await Bun.file(ENV_FILE).exists())) {
log.error("No local .env file found.");
return 1;
}
const password = await promptPassword("Enter decryption password: ");
if (!password) {
log.error("Password cannot be empty.");
return 1;
}
try {
const storedContent = await loadEncryptedEnv(projectPath, password);
const localContent = await Bun.file(ENV_FILE).text();
const { added, removed, changed } = computeDiff(
parseEnvFile(localContent),
parseEnvFile(storedContent),
);
if (!added.length && !removed.length && !changed.length) {
log.success("No differences found.");
return 0;
}
for (const key of added) console.log(`${c.g}+ ${key}${c.x}`);
for (const key of removed) console.log(`${c.r}- ${key}${c.x}`);
for (const key of changed)
console.log(`${c.y}~ ${key}${c.x} (value changed)`);
return 0;
} catch (e) {
log.error(
e instanceof Error ? e.message : "Decryption failed. Wrong password?",
);
return 1;
}
}
examples/03-complex/src/commands/init.ts
// Initialize env-sync in current project
import { basename, resolve } from "node:path";
import { $ } from "bun";
import type { Log } from "../index";
import { projectExists, saveProjectMeta } from "../lib/config";
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function getProjectName(cwd: string): string {
return basename(cwd);
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
async function isGitRepo(): Promise<boolean> {
const { exitCode } = await $`git rev-parse --git-dir`.nothrow().quiet();
return exitCode === 0;
}
export async function init(log: Log): Promise<number> {
const projectPath = resolve(process.cwd());
if (!(await isGitRepo())) {
log.warning("Not a git repository. Initializing anyway.");
}
if (await projectExists(projectPath)) {
log.error("Project already initialized. Use 'push' to update.");
return 1;
}
await saveProjectMeta(projectPath, {
name: getProjectName(projectPath),
path: projectPath,
createdAt: new Date().toISOString(),
});
log.success(`Initialized env-sync for '${getProjectName(projectPath)}'`);
log.info("Run 'env-sync push' to encrypt and store your .env");
return 0;
}
examples/03-complex/src/commands/list.ts
// List all synced projects
import type { Log } from "../index";
import { listProjects } from "../lib/storage";
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function list(log: Log): Promise<number> {
const projects = await listProjects();
if (!projects.length) {
log.info("No projects initialized yet.");
return 0;
}
console.log("\nSynced projects:\n");
for (const { meta } of projects) {
console.log(
` ${meta.name}\n Path: ${meta.path}\n Created: ${meta.createdAt}\n`,
);
}
return 0;
}
examples/03-complex/src/commands/pull.ts
// Pull and decrypt .env from storage
import { resolve } from "node:path";
import type { Log } from "../index";
import { projectExists } from "../lib/config";
import { confirm, promptPassword } from "../lib/prompt";
import { loadEncryptedEnv } from "../lib/storage";
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function pull(log: Log): Promise<number> {
const projectPath = resolve(process.cwd());
if (!(await projectExists(projectPath))) {
log.error("Project not initialized. Run 'env-sync init' first.");
return 1;
}
if (
(await Bun.file(".env").exists()) &&
!(await confirm("Overwrite existing .env?"))
) {
log.info("Aborted.");
return 0;
}
const password = await promptPassword("Enter decryption password: ");
if (!password) {
log.error("Password cannot be empty.");
return 1;
}
try {
const content = await loadEncryptedEnv(projectPath, password);
await Bun.write(".env", content);
log.success("Decrypted .env written successfully.");
return 0;
} catch (e) {
log.error(
e instanceof Error ? e.message : "Decryption failed. Wrong password?",
);
return 1;
}
}
examples/03-complex/src/commands/push.ts
// Encrypt and push .env to storage
import { resolve } from "node:path";
import type { Log } from "../index";
import { projectExists } from "../lib/config";
import { promptPassword } from "../lib/prompt";
import { saveEncryptedEnv } from "../lib/storage";
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function push(log: Log): Promise<number> {
const projectPath = resolve(process.cwd());
if (!(await projectExists(projectPath))) {
log.error("Project not initialized. Run 'env-sync init' first.");
return 1;
}
const envFile = Bun.file(".env");
if (!(await envFile.exists())) {
log.error("No .env file found in current directory.");
return 1;
}
const content = await envFile.text();
if (!content.trim()) {
log.error(".env file is empty.");
return 1;
}
const password = await promptPassword("Enter encryption password: ");
if (!password) {
log.error("Password cannot be empty.");
return 1;
}
const confirmPwd = await promptPassword("Confirm password: ");
if (password !== confirmPwd) {
log.error("Passwords do not match.");
return 1;
}
await saveEncryptedEnv(projectPath, content, password);
log.success("Encrypted .env stored successfully.");
return 0;
}
examples/03-complex/src/index.ts
#!/usr/bin/env bun
// env-sync: Sync .env files across machines with encryption
import { diff } from "./commands/diff";
import { init } from "./commands/init";
import { list } from "./commands/list";
import { pull } from "./commands/pull";
import { push } from "./commands/push";
import { loadGlobalConfig, saveGlobalConfig } from "./lib/config";
// ============================================================================
// TYPES
// ============================================================================
export interface Log {
info: (msg: string) => void;
success: (msg: string) => void;
warning: (msg: string) => void;
error: (msg: string) => void;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const VERSION = "1.0.0";
const USAGE = `env-sync v${VERSION} - Sync .env files with encryption
Commands:
init Initialize in current project
push Encrypt and push .env
pull Pull and decrypt .env
diff Show differences
list List synced projects
config set remote <url>
Options:
--help Show this help
--version Show version`;
const c = {
r: "\x1b[0;31m",
g: "\x1b[0;32m",
y: "\x1b[1;33m",
b: "\x1b[0;34m",
x: "\x1b[0m",
};
const log: Log = {
info: (m) => console.log(`${c.b}i${c.x} ${m}`),
success: (m) => console.log(`${c.g}v${c.x} ${m}`),
warning: (m) => console.log(`${c.y}!${c.x} ${m}`),
error: (m) => console.error(`${c.r}x${c.x} ${m}`),
};
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function parseArgs(args: string[]): {
command?: string;
subArgs: string[];
help: boolean;
version: boolean;
} {
const result: {
command?: string;
subArgs: string[];
help: boolean;
version: boolean;
} = {
subArgs: [],
help: false,
version: false,
};
for (const arg of args) {
if (arg === "--help" || arg === "-h") result.help = true;
else if (arg === "--version") result.version = true;
else if (!arg.startsWith("-") && !result.command) result.command = arg;
else if (result.command) result.subArgs.push(arg);
}
return result;
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
async function handleConfig(subArgs: string[]): Promise<number> {
if (subArgs[0] !== "set" || subArgs[1] !== "remote" || !subArgs[2]) {
log.error("Usage: env-sync config set remote <url>");
return 1;
}
const config = await loadGlobalConfig();
config.remote = subArgs[2];
await saveGlobalConfig(config);
log.success(`Remote set to: ${subArgs[2]}`);
return 0;
}
const commands: Record<string, (log: Log) => Promise<number>> = {
init,
push,
pull,
diff,
list,
};
async function main(): Promise<void> {
const { command, subArgs, help, version } = parseArgs(Bun.argv.slice(2));
if (version) {
console.log(VERSION);
return;
}
if (help || !command) {
console.log(USAGE);
process.exit(help ? 0 : 1);
}
let exitCode: number;
if (command === "config") {
exitCode = await handleConfig(subArgs);
} else if (commands[command]) {
exitCode = await commands[command](log);
} else {
log.error(`Unknown command: ${command}`);
console.log(USAGE);
exitCode = 1;
}
process.exit(exitCode);
}
if (import.meta.main) {
main().catch((e) => {
log.error(e instanceof Error ? e.message : String(e));
process.exit(1);
});
}
examples/03-complex/src/lib/config.ts
// Configuration management for env-sync
import { createHash } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
// ============================================================================
// TYPES
// ============================================================================
export interface GlobalConfig {
remote?: string;
}
export interface ProjectMeta {
name: string;
path: string;
createdAt: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const CONFIG_DIR = join(homedir(), ".env-sync");
const PROJECTS_DIR = join(CONFIG_DIR, "projects");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function hashPath(path: string): string {
return createHash("sha256").update(path).digest("hex").slice(0, 16);
}
export const getConfigDir = () => CONFIG_DIR;
export const getProjectDir = (projectPath: string) =>
join(PROJECTS_DIR, hashPath(projectPath));
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function ensureConfigDir(): Promise<void> {
await mkdir(PROJECTS_DIR, { recursive: true });
}
export async function loadGlobalConfig(): Promise<GlobalConfig> {
const file = Bun.file(CONFIG_FILE);
if (!(await file.exists())) return {};
try {
return await file.json();
} catch {
return {};
}
}
export async function saveGlobalConfig(config: GlobalConfig): Promise<void> {
await ensureConfigDir();
await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
}
export async function loadProjectMeta(
projectPath: string,
): Promise<ProjectMeta | null> {
const file = Bun.file(join(getProjectDir(projectPath), "meta.json"));
if (!(await file.exists())) return null;
try {
return await file.json();
} catch {
return null;
}
}
export async function saveProjectMeta(
projectPath: string,
meta: ProjectMeta,
): Promise<void> {
const dir = getProjectDir(projectPath);
await mkdir(dir, { recursive: true });
await Bun.write(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
}
export async function projectExists(projectPath: string): Promise<boolean> {
return Bun.file(join(getProjectDir(projectPath), "meta.json")).exists();
}
examples/03-complex/src/lib/crypto.ts
// Encryption utilities using Web Crypto API (AES-GCM)
// ============================================================================
// CONSTANTS
// ============================================================================
const PBKDF2_ITERATIONS = 100000;
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
// ============================================================================
// PURE FUNCTIONS (exported for testing)
// ============================================================================
export function concatBuffers(...buffers: Uint8Array[]): Uint8Array {
const result = new Uint8Array(buffers.reduce((sum, b) => sum + b.length, 0));
let offset = 0;
for (const buf of buffers) {
result.set(buf, offset);
offset += buf.length;
}
return result;
}
export function splitEncryptedData(data: Uint8Array) {
return {
salt: data.slice(0, SALT_LENGTH),
iv: data.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH),
ciphertext: data.slice(SALT_LENGTH + IV_LENGTH),
};
}
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
async function deriveKey(
password: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt.buffer as ArrayBuffer,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export async function encrypt(
plaintext: string,
password: string,
): Promise<Uint8Array> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(password, salt);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext),
);
return concatBuffers(salt, iv, new Uint8Array(encrypted));
}
export async function decrypt(
data: Uint8Array,
password: string,
): Promise<string> {
const { salt, iv, ciphertext } = splitEncryptedData(data);
const key = await deriveKey(password, salt);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv.buffer as ArrayBuffer },
key,
ciphertext.buffer as ArrayBuffer,
);
return new TextDecoder().decode(decrypted);
}
examples/03-complex/src/lib/prompt.ts
// Interactive prompts for CLI
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function prompt(question: string): Promise<string> {
process.stdout.write(question);
for await (const line of console) return line.trim();
return "";
}
// Note: No input masking in this demo. For production, use a TTY raw mode library.
export async function promptPassword(question: string): Promise<string> {
return prompt(question);
}
export async function confirm(question: string): Promise<boolean> {
process.stdout.write(`${question} [Y/n] `);
for await (const line of console) return line.trim().toLowerCase() !== "n";
return false;
}
examples/03-complex/src/lib/storage.ts
// Local file-based storage for encrypted .env files
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { getConfigDir, getProjectDir, type ProjectMeta } from "./config";
import { decrypt, encrypt } from "./crypto";
// ============================================================================
// CONSTANTS
// ============================================================================
const ENV_FILE = "env.enc";
const PROJECTS_DIR = join(getConfigDir(), "projects");
// ============================================================================
// SIDE-EFFECTING FUNCTIONS
// ============================================================================
export async function saveEncryptedEnv(
projectPath: string,
content: string,
password: string,
): Promise<void> {
const encrypted = await encrypt(content, password);
await Bun.write(join(getProjectDir(projectPath), ENV_FILE), encrypted);
}
export async function loadEncryptedEnv(
projectPath: string,
password: string,
): Promise<string> {
const file = Bun.file(join(getProjectDir(projectPath), ENV_FILE));
if (!(await file.exists())) {
throw new Error("No encrypted .env found. Run 'env-sync push' first.");
}
return decrypt(await file.bytes(), password);
}
export async function hasStoredEnv(projectPath: string): Promise<boolean> {
return Bun.file(join(getProjectDir(projectPath), ENV_FILE)).exists();
}
export async function listProjects(): Promise<
Array<{ hash: string; meta: ProjectMeta }>
> {
let entries: string[];
try {
entries = await readdir(PROJECTS_DIR);
} catch {
return [];
}
const projects: Array<{ hash: string; meta: ProjectMeta }> = [];
for (const hash of entries) {
const metaFile = Bun.file(join(PROJECTS_DIR, hash, "meta.json"));
if (await metaFile.exists()) {
try {
const meta = (await metaFile.json()) as ProjectMeta;
projects.push({ hash, meta });
} catch {
// Skip invalid meta files
}
}
}
return projects;
}
examples/03-complex/tests/config.test.ts
import { describe, expect, test } from "bun:test";
import { computeDiff, parseEnvFile } from "../src/commands/diff";
import { getProjectName } from "../src/commands/init";
import { parseArgs } from "../src/index";
import { hashPath } from "../src/lib/config";
describe("hashPath", () => {
test("returns consistent hash for same path", () => {
const hash1 = hashPath("/home/user/project");
const hash2 = hashPath("/home/user/project");
expect(hash1).toBe(hash2);
});
test("returns different hash for different paths", () => {
const hash1 = hashPath("/home/user/project1");
const hash2 = hashPath("/home/user/project2");
expect(hash1).not.toBe(hash2);
});
test("returns 16 character hash", () => {
const hash = hashPath("/some/path");
expect(hash.length).toBe(16);
});
});
describe("getProjectName", () => {
test("extracts basename from path", () => {
expect(getProjectName("/home/user/my-project")).toBe("my-project");
});
});
describe("parseEnvFile", () => {
test("parses key=value pairs", () => {
const content = "KEY1=value1\nKEY2=value2";
const vars = parseEnvFile(content);
expect(vars.get("KEY1")).toBe("value1");
expect(vars.get("KEY2")).toBe("value2");
});
test("ignores comments and empty lines", () => {
const content = "# Comment\nKEY=value\n\n # Another comment";
const vars = parseEnvFile(content);
expect(vars.size).toBe(1);
expect(vars.get("KEY")).toBe("value");
});
test("handles values with equals signs", () => {
const content = "URL=https://example.com?foo=bar";
const vars = parseEnvFile(content);
expect(vars.get("URL")).toBe("https://example.com?foo=bar");
});
});
describe("computeDiff", () => {
test("detects added keys", () => {
const local = new Map([
["A", "1"],
["B", "2"],
]);
const stored = new Map([["A", "1"]]);
const { added, removed, changed } = computeDiff(local, stored);
expect(added).toEqual(["B"]);
expect(removed).toEqual([]);
expect(changed).toEqual([]);
});
test("detects removed keys", () => {
const local = new Map([["A", "1"]]);
const stored = new Map([
["A", "1"],
["B", "2"],
]);
const { added, removed, changed } = computeDiff(local, stored);
expect(added).toEqual([]);
expect(removed).toEqual(["B"]);
expect(changed).toEqual([]);
});
test("detects changed values", () => {
const local = new Map([["A", "new"]]);
const stored = new Map([["A", "old"]]);
const { added, removed, changed } = computeDiff(local, stored);
expect(added).toEqual([]);
expect(removed).toEqual([]);
expect(changed).toEqual(["A"]);
});
});
describe("parseArgs", () => {
test("parses command", () => {
const result = parseArgs(["init"]);
expect(result.command).toBe("init");
});
test("parses help flag", () => {
const result = parseArgs(["--help"]);
expect(result.help).toBe(true);
});
test("parses version flag", () => {
const result = parseArgs(["--version"]);
expect(result.version).toBe(true);
});
test("parses command with subArgs", () => {
const result = parseArgs(["config", "set", "remote", "http://foo"]);
expect(result.command).toBe("config");
expect(result.subArgs).toEqual(["set", "remote", "http://foo"]);
});
});
describe("parseArgs edge cases", () => {
test("handles empty args", () => {
const result = parseArgs([]);
expect(result.command).toBeUndefined();
expect(result.help).toBe(false);
expect(result.version).toBe(false);
});
test("handles short help flag", () => {
const result = parseArgs(["-h"]);
expect(result.help).toBe(true);
});
test("ignores flags before command", () => {
const result = parseArgs(["--help", "init"]);
expect(result.command).toBe("init");
expect(result.help).toBe(true);
});
});
examples/03-complex/tests/crypto.test.ts
import { describe, expect, test } from "bun:test";
import {
concatBuffers,
decrypt,
encrypt,
splitEncryptedData,
} from "../src/lib/crypto";
describe("concatBuffers", () => {
test("concatenates multiple buffers", () => {
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4, 5]);
const c = new Uint8Array([6]);
const result = concatBuffers(a, b, c);
expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6]));
});
test("handles empty buffers", () => {
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([]);
const result = concatBuffers(a, b);
expect(result).toEqual(new Uint8Array([1, 2]));
});
});
describe("splitEncryptedData", () => {
test("splits data into salt, iv, and ciphertext", () => {
const data = new Uint8Array(50);
for (let i = 0; i < 50; i++) data[i] = i;
const { salt, iv, ciphertext } = splitEncryptedData(data);
expect(salt.length).toBe(16);
expect(iv.length).toBe(12);
expect(ciphertext.length).toBe(22);
expect(salt[0]).toBe(0);
expect(iv[0]).toBe(16);
expect(ciphertext[0]).toBe(28);
});
});
describe("encrypt/decrypt roundtrip", () => {
test("encrypts and decrypts text correctly", async () => {
const plaintext = "SECRET_KEY=abc123\nAPI_TOKEN=xyz789";
const password = "testpassword";
const encrypted = await encrypt(plaintext, password);
expect(encrypted.length).toBeGreaterThan(plaintext.length);
const decrypted = await decrypt(encrypted, password);
expect(decrypted).toBe(plaintext);
});
test("fails with wrong password", async () => {
const plaintext = "SECRET=value";
const encrypted = await encrypt(plaintext, "correct");
await expect(decrypt(encrypted, "wrong")).rejects.toThrow();
});
test("handles empty string", async () => {
const encrypted = await encrypt("", "pass");
const decrypted = await decrypt(encrypted, "pass");
expect(decrypted).toBe("");
});
test("handles unicode content", async () => {
const plaintext =
"EMOJI=\u{1F680}\nJAPANESE=\u3053\u3093\u306B\u3061\u306F";
const encrypted = await encrypt(plaintext, "pass");
const decrypted = await decrypt(encrypted, "pass");
expect(decrypted).toBe(plaintext);
});
});
examples/03-complex/tsconfig.json
{
"extends": "../tsconfig.base.json",
"include": ["**/*.ts"]
}
examples/tsconfig.base.json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"noEmit": true
}
}
LICENSE
MIT License
Copyright (c) 2026 Augustin BENGOLEA <augustin.bengolea@gmail.com>
Permission is hereby granted,
free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice
(including the next paragraph) shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
references/bun-api.md
# Bun API Reference
Runtime APIs that matter most for Bun CLIs and small Bun-native tools.
## Table of Contents
- [Bun Shell](#bun-shell)
- [Spawn](#spawn)
- [File I/O](#file-io)
- [Glob](#glob)
- [Archive](#archive)
- [Utilities](#utilities)
---
## Bun Shell
Import `$` from `bun` for shell commands with automatic escaping of interpolated
values.
```typescript
import { $ } from "bun";
const branch = await $`git rev-parse --abbrev-ref HEAD`.text();
const args = ["log", "--oneline", "-3"];
await $`git ${args}`;
```
### Output and errors
| Pattern | Use |
|---|---|
| `await $\`cmd\`` | Run and inherit output; throws on non-zero exit |
| `await $\`cmd\`.text()` | Capture stdout as a string; does not trim |
| `await $\`cmd\`.json<T>()` | Parse stdout JSON |
| `for await (const line of $\`cmd\`.lines())` | Stream stdout by line |
| `await $\`cmd\`.quiet()` | Capture stdout/stderr as Buffers |
| `await $\`cmd\`.nothrow().quiet()` | Inspect `exitCode` manually |
```typescript
const { stdout, stderr, exitCode } = await $`git status --short`
.nothrow()
.quiet();
if (exitCode !== 0) {
throw new Error(`git failed: ${stderr.toString()}`);
}
console.log(stdout.toString().trim());
```
Use `$.nothrow()` / `$.throws(true)` only for deliberate global policy. For most
scripts, prefer command-local `.nothrow()` so failures stay visible.
### Security boundary
Interpolated values are escaped by Bun Shell:
```typescript
const path = "file.txt; rm -rf /";
await $`cat ${path}`; // treated as one escaped argument
```
That protection does not extend into a shell you explicitly invoke:
```typescript
const script = `cat ${path}`;
await $`bash -c ${script}`; // bash interprets script
```
Avoid `bash -c` for user-controlled strings. If you need raw shell scripts, build
the script from constants and pass untrusted data as separate args or environment.
Bun Shell cannot know whether an external program treats an argument as a flag:
```typescript
const branch = "--upload-pack=echo pwned";
await $`git ls-remote origin ${branch}`; // one safe argument, but dangerous to git
```
Validate user-controlled arguments against the target command's own semantics.
---
## Spawn
Use `Bun.spawn()` when you need streaming, long-running processes, or precise stdio.
Use `Bun.spawnSync()` only when blocking is acceptable.
```typescript
const proc = Bun.spawn(["git", "status", "--short"], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(`git failed: ${stderr}`);
}
console.log(stdout);
```
```typescript
const result = Bun.spawnSync(["/usr/bin/git", "status", "--short"]);
if (!result.success) {
throw new Error(result.stderr.toString());
}
```
Prefer absolute paths or `Bun.which()` for binaries where PATH spoofing matters.
---
## File I/O
`Bun.file()` is lazy; it creates a file reference and reads only when a method is
called.
```typescript
const file = Bun.file("data.json");
if (!(await file.exists())) {
throw new Error("data.json not found");
}
const data = await file.json();
```
Important details:
- `exists()` returns `Promise<boolean>`; an un-awaited promise is always truthy.
- A `BunFile` can point to a missing file; `size` may be `0` before existence is checked.
- `Bun.write()` accepts string paths, `file://` URLs, `BunFile`, `Blob`, typed arrays,
`ArrayBuffer`, `Response`, and other supported binary data.
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive `rm`).
```typescript
await Bun.write("output.json", JSON.stringify(data, null, 2));
await Bun.write("copy.txt", Bun.file("source.txt"));
await Bun.write("page.html", await fetch("https://example.com"));
```
Incremental writes:
```typescript
const writer = Bun.file("large.log").writer();
writer.write("line 1\n");
writer.write("line 2\n");
writer.end();
```
---
## Glob
Use `Bun.Glob` for fast file pattern matching without adding a dependency.
```typescript
import { Glob } from "bun";
const glob = new Glob("src/**/*.{ts,tsx}");
for await (const path of glob.scan({ cwd: process.cwd() })) {
console.log(path);
}
const files = Array.from(new Glob("*.json").scanSync({ cwd: "config" }));
console.log(new Glob("*.test.ts").match("cli.test.ts")); // true
```
Common scan options:
| Option | Use |
|---|---|
| `cwd` | root directory for matching |
| `dot` | include entries beginning with `.` |
| `absolute` | return absolute paths |
| `followSymlinks` | traverse symlinked directories |
| `throwErrorOnBrokenSymlink` | fail on broken symlinks |
| `onlyFiles` | return only files; defaults to `true` |
Supported patterns include `?`, `*`, `**`, character classes, nested braces,
leading `!` negation, and backslash escapes.
---
## Archive
`Bun.Archive` works with tar and tar.gz data. It can create archives from an object
map, extract archives to disk, or inspect regular files without extracting.
```typescript
const archive = new Bun.Archive({
"README.md": "# Project",
"src/index.ts": "console.log('hi')",
});
await Bun.write("bundle.tar", archive);
await Bun.write("bundle.tar.gz", new Bun.Archive({
"README.md": "# Project",
"src/index.ts": "console.log('hi')",
}, { compress: "gzip" }));
```
Prefer a plain object when creating archives:
```typescript
const compressed = new Bun.Archive(
{ "src/index.ts": await Bun.file("src/index.ts").text() },
{ compress: "gzip", level: 9 },
);
await Bun.write("src.tar.gz", compressed);
await Bun.Archive.write("src.tar.gz", {
"src/index.ts": await Bun.file("src/index.ts").text(),
}, { compress: "gzip" });
```
Extracting:
```typescript
const response = await fetch(url);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const archive = new Bun.Archive(await response.blob());
const count = await archive.extract("./output", {
glob: ["**", "!node_modules/**"],
});
console.log(`Extracted ${count} entries`);
```
Notes:
- `extract()` creates the target directory and overwrites existing files.
- `extract()` returns the number of extracted entries, including files, directories,
and symlinks on POSIX systems.
- Negative glob patterns require a positive pattern such as `**` first.
- `files()` returns regular files only and loads contents into memory.
- Bun rejects absolute archive paths and normalizes `..` traversal during extraction,
but still validate untrusted archive contents against your own allowlist.
---
## Utilities
```typescript
const git = Bun.which("git");
if (!git) throw new Error("git not found");
const args = Bun.argv.slice(2);
await Bun.sleep(100);
if (import.meta.main) {
// CLI entrypoint
}
```
Use `process.versions.bun` when runtime detection is needed and `@types/bun` may
not be installed.
references/bundler.md
# Bundler and Executables
Bun's bundler handles TypeScript, JSX/TSX, CSS, assets, and Bun-native runtime
targets. Use it directly for simple builds; keep framework build systems when a
project already has one.
## Basic Build
```typescript
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "bun",
});
console.log(result.outputs.map((output) => output.path));
```
`Bun.build()` rejects on failure by default. Catch `AggregateError` for normal
failure handling:
```typescript
try {
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "bun",
});
} catch (error) {
const aggregate = error as AggregateError;
console.error(aggregate.errors);
process.exit(1);
}
```
## Common Options
```typescript
const result = await Bun.build({
entrypoints: ["./src/index.ts", "./src/worker.ts"],
outdir: "./dist",
target: "bun", // "browser" | "bun" | "node"
format: "esm", // "esm" | "cjs" | "iife"
splitting: true, // ESM code splitting
minify: true,
sourcemap: "linked", // "none" | "linked" | "inline" | "external"
external: ["react"],
packages: "external", // mark all package imports external
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
env: "inline", // or prefix such as "PUBLIC_*"
loader: { ".txt": "text", ".png": "file" },
naming: {
entry: "[dir]/[name]-[hash].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
asset: "assets/[name]-[hash].[ext]",
},
drop: ["console", "debugger"],
metafile: true,
});
for (const output of result.outputs) {
console.log(output.kind, output.path);
}
```
Notes:
- JavaScript API builds default to `target: "browser"` unless you set `target`.
- Bundles with `target: "bun"` include a `// @bun` pragma so Bun does not
retranspile them at runtime.
- `packages: "external"` is useful for server builds that should keep runtime
dependencies external.
## Standalone Executables
Prefer the current object form for API builds:
```typescript
const result = await Bun.build({
entrypoints: ["./src/cli.ts"],
compile: {
target: "bun-linux-x64",
outfile: "./dist/mycli",
execArgv: ["--smol"],
autoloadDotenv: false,
autoloadBunfig: false,
},
minify: true,
bytecode: true,
});
console.log(result.outputs[0]?.path);
```
CLI equivalent:
```bash
bun build ./src/cli.ts --compile --outfile mycli
bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile mycli-linux
```
Common documented targets:
| Target | OS | Arch | Libc |
|---|---|---|---|
| `bun-linux-x64` | Linux | x64 | glibc |
| `bun-linux-x64-baseline` | Linux | x64 | glibc |
| `bun-linux-x64-modern` | Linux | x64 | glibc |
| `bun-linux-arm64` | Linux | arm64 | glibc |
| `bun-linux-x64-musl` | Linux | x64 | musl |
| `bun-linux-x64-musl-baseline` | Linux | x64 | musl |
| `bun-linux-x64-musl-modern` | Linux | x64 | musl |
| `bun-linux-arm64-musl` | Linux | arm64 | musl |
| `bun-darwin-x64` | macOS | x64 | system |
| `bun-darwin-x64-baseline` | macOS | x64 | system |
| `bun-darwin-arm64` | macOS | arm64 | system |
| `bun-windows-x64` | Windows | x64 | system |
| `bun-windows-x64-baseline` | Windows | x64 | system |
| `bun-windows-x64-modern` | Windows | x64 | system |
| `bun-windows-arm64` | Windows | arm64 | system |
Prefer documented `arm64` target strings in build scripts. Bun's types also accept
some `aarch64` aliases. Use `-baseline` when users report `Illegal instruction`
on older x64 machines; `-modern` is most relevant for Linux and Windows x64.
`compile` accepts three JavaScript API forms:
```typescript
compile: true;
compile: "bun-linux-x64";
compile: { target: "bun-linux-x64", outfile: "./mycli" };
```
With `target: "browser"` and HTML entrypoints, `--compile` produces self-contained
HTML rather than a native executable.
## Guardrails
- Compile CLIs from a single entrypoint; keep dynamic plugin loading external or
test it in the compiled binary.
- Test the generated executable, not only the TypeScript source.
- Do not assume cross-compiled binaries behave identically across OS path,
permission, and shell semantics.
- For Windows executables, Bun adds `.exe` when no extension is provided.
## Official Docs
- `https://bun.sh/docs/bundler`
- `https://bun.sh/docs/bundler/executables`
references/crypto.md
# Crypto and Hashing
Bun exposes password hashing, cryptographic hashing, HMAC, and fast
non-cryptographic hashes. Use Web Crypto or `node:crypto` when a project already
standardizes on those APIs.
## Password Hashing
`Bun.password` generates salts automatically and stores the algorithm in the hash.
The default algorithm is `argon2id`; `verify()` auto-detects PHC/MCF formats.
```typescript
const hash = await Bun.password.hash("my-password");
const ok = await Bun.password.verify("my-password", hash);
```
Configure algorithms deliberately:
```typescript
const argonHash = await Bun.password.hash("my-password", {
algorithm: "argon2id",
memoryCost: 65536,
timeCost: 3,
});
const bcryptHash = await Bun.password.hash("my-password", {
algorithm: "bcrypt",
cost: 12,
});
```
Supported password algorithms are `argon2id`, `argon2d`, `argon2i`, and `bcrypt`.
For bcrypt, be careful with passwords longer than bcrypt's native input limit;
Bun pre-hashes long inputs, and implementation details can differ between async
and sync APIs.
Sync variants exist, but they are CPU-expensive and block the runtime:
```typescript
const hash = Bun.password.hashSync("my-password");
const ok = Bun.password.verifySync("my-password", hash);
```
Use sync hashing only in CLIs, migrations, tests, or startup paths where blocking
is acceptable.
## Cryptographic Hashing
```typescript
const hasher = new Bun.CryptoHasher("sha256");
hasher.update("hello ");
hasher.update("world");
const digest = hasher.digest("hex");
```
`update()` accepts strings, typed arrays, and array buffers. String updates can pass
an encoding as the second argument.
HMAC:
```typescript
const hmac = new Bun.CryptoHasher("sha256", "secret-key");
hmac.update("hello world");
console.log(hmac.digest("hex"));
```
For non-HMAC hashers, `digest()` finalizes and resets the hasher so it can be
reused. For HMAC, do not rely on reuse after `digest()`; use `.copy()` before
digesting when you need variants from the same intermediate state.
## Non-Cryptographic Hashing
`Bun.hash` is fast and not security-safe.
```typescript
Bun.hash("data");
Bun.hash("data", 1234n);
Bun.hash.crc32("data");
Bun.hash.xxHash64("data");
Bun.hash.rapidhash("data");
```
Use this for sharding, cache keys, or checksums where collision resistance is not a
security property.
## Guardrails
- Never use `Bun.hash` for passwords, tokens, signatures, or integrity checks against
attackers.
- Prefer async password hashing in servers.
- Store password hashes exactly as returned; do not split out salts manually.
- Use `crypto.randomUUID()` or Web Crypto for random values, not hashes of predictable data.
## Official Docs
- `https://bun.sh/docs/api/hashing`
- `https://bun.sh/docs/runtime/hashing`
references/http-server.md
# HTTP Server and WebSockets
Use `Bun.serve()` for Bun-native HTTP servers. On Bun v1.2.3+, prefer the `routes`
object for simple routing and `fetch` as fallback or when request handling is
highly dynamic.
## Routes
```typescript
const server = Bun.serve({
port: 3000,
routes: {
"/api/health": new Response("OK"),
"/users/:id": (req) => {
return Response.json({ id: req.params.id });
},
"/api/posts": {
GET: () => Response.json({ posts: [] }),
POST: async (req) => {
const body = await req.json();
return Response.json({ created: true, ...body }, { status: 201 });
},
},
"/api/*": Response.json({ message: "Not found" }, { status: 404 }),
"/favicon.ico": new Response(Bun.file("./favicon.ico")),
},
fetch() {
return new Response("Not Found", { status: 404 });
},
error(error) {
console.error(error);
return new Response("Internal Server Error", { status: 500 });
},
});
console.log(`Listening on ${server.url}`);
```
Use `fetch(req, server)` instead of `routes` when you need custom dispatch,
streaming setup, or compatibility with older Bun versions.
## Server Lifecycle
```typescript
await server.stop(); // graceful
await server.stop(true); // close active connections
server.reload({
routes: {
"/api/version": Response.json({ version: "2.0.0" }),
},
});
server.unref(); // allow process exit if this is the only active handle
server.ref();
```
Useful properties and methods:
- `server.url`, `server.port`, `server.hostname`
- `server.pendingRequests`, `server.pendingWebSockets`
- `server.requestIP(req)`
- `server.timeout(req, seconds)` for per-request idle timeout
The default idle timeout is 10 seconds, the maximum is 255 seconds, and `0`
disables it. For SSE or slow streaming responses, call `server.timeout(req, 0)`.
## WebSocket Upgrade
```typescript
type WsData = { username: string; joinedAt: number };
const server: Bun.Server<WsData> = Bun.serve({
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const ok = server.upgrade(req, {
data: {
username: url.searchParams.get("user") ?? "anon",
joinedAt: Date.now(),
},
});
return ok ? undefined : new Response("Upgrade failed", { status: 500 });
}
return new Response("Hello");
},
websocket: {
data: {} as WsData,
open(ws) {
ws.subscribe("chat");
server.publish("chat", `${ws.data.username} joined`);
},
message(ws, message) {
server.publish("chat", `${ws.data.username}: ${message}`);
},
close(ws) {
ws.unsubscribe("chat");
},
drain() {
// backpressure relieved
},
idleTimeout: 120,
maxPayloadLength: 1024 * 1024,
perMessageDeflate: true,
sendPings: true,
},
});
```
Gotchas:
- On successful `server.upgrade()`, return `undefined`, not a `Response`.
- Type WebSocket `data` with `const server: Bun.Server<WsData> = Bun.serve(...)`
and `websocket.data: {} as WsData`.
- `server.publish()` returns bytes sent, `0` if dropped, and `-1` on backpressure.
## Static and File Responses
```typescript
routes: {
"/health": new Response("OK"),
"/download.zip": new Response(Bun.file("./download.zip")),
}
```
Static `Response` objects are cached for the server lifetime. File responses using
`Bun.file()` read from disk per request and support filesystem-driven behavior such
as missing-file 404s and range requests.
## Official Docs
- `https://bun.sh/docs/api/http`
- `https://bun.sh/docs/runtime/http/routing`
- `https://bun.sh/docs/api/websockets`
references/patterns.md
# CLI Patterns
Reusable patterns for Bun CLI scripts. These are workflow patterns, not API
reference.
## Logging
```typescript
const colors = {
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
reset: "\x1b[0m",
} as const;
export const log = {
info: (message: string) => console.log(`${colors.blue}info${colors.reset} ${message}`),
success: (message: string) => console.log(`${colors.green}ok${colors.reset} ${message}`),
warn: (message: string) => console.error(`${colors.yellow}warn${colors.reset} ${message}`),
error: (message: string) => console.error(`${colors.red}error${colors.reset} ${message}`),
};
```
Keep library functions silent. Print only in CLI adapters or top-level command
handlers.
## Confirmation
```typescript
export async function confirm(prompt: string, defaultValue = false): Promise<boolean> {
const suffix = defaultValue ? "[Y/n]" : "[y/N]";
process.stdout.write(`${prompt} ${suffix} `);
for await (const line of console) {
const answer = line.trim().toLowerCase();
if (answer === "") return defaultValue;
if (answer === "y" || answer === "yes") return true;
if (answer === "n" || answer === "no") return false;
process.stdout.write("Please answer yes or no: ");
}
return defaultValue;
}
```
Do not prompt in non-interactive paths unless the command explicitly supports it.
Accept `--yes`, `--no`, or `--force` for automation.
## Error Boundary
```typescript
export async function main(): Promise<void> {
// command logic
}
if (import.meta.main) {
main().catch((error: unknown) => {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
```
Wrap errors with context at the boundary where the operation becomes meaningful:
```typescript
async function readConfig(path: string): Promise<Config> {
try {
return await Bun.file(path).json();
} catch (error) {
throw new Error(`Failed to read config ${path}`, { cause: error });
}
}
```
## Parallel Work
Use unbounded `Promise.all` only for small known lists. For filesystem, network, or
subprocess fan-out, add a small concurrency limit.
```typescript
async function mapLimit<T, R>(
values: readonly T[],
limit: number,
fn: (value: T) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(values.length);
let next = 0;
async function worker(): Promise<void> {
while (next < values.length) {
const index = next++;
results[index] = await fn(values[index] as T);
}
}
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
return results;
}
```
## Exit Codes
Prefer returning data from functions and deciding the exit code in `main()`.
```typescript
const ok = await runChecks();
process.exit(ok ? 0 : 1);
```
Use distinct exit codes only when callers are documented to depend on them.
references/sqlite.md
# SQLite
Bun ships a synchronous SQLite driver as `bun:sqlite`. Use it for local CLIs,
embedded data stores, tests, and small services where blocking the event loop is
acceptable.
## Open a Database
```typescript
import { Database } from "bun:sqlite";
const db = new Database("app.sqlite");
const memoryDb = new Database(":memory:");
const readonlyDb = new Database("app.sqlite", { readonly: true });
const strictDb = new Database(":memory:", { strict: true });
const safeIntDb = new Database(":memory:", { safeIntegers: true });
```
Set journal mode deliberately instead of assuming defaults:
```typescript
db.run("PRAGMA journal_mode = WAL;");
```
## Statements
```typescript
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`);
const byId = db.query("SELECT * FROM users WHERE id = ?");
const user = byId.get(1);
const allUsers = db.query("SELECT * FROM users").all();
const names = db.query("SELECT name FROM users").values();
```
Use `iterate()` for large result sets:
```typescript
for (const row of db.query("SELECT * FROM users").iterate()) {
console.log(row);
}
```
## Parameters
Use parameters for all user-provided values.
```typescript
db.query("SELECT * FROM users WHERE name = ? AND age > ?").all("Alice", 18);
db.query("INSERT INTO users (name, email) VALUES ($name, $email)").run({
$name: "Bob",
$email: "bob@example.com",
});
```
With `{ strict: true }`, named parameter object keys omit the prefix:
```typescript
const db = new Database(":memory:", { strict: true });
db.query("SELECT $message").all({ message: "hello" });
```
`strict: true` also throws when a required named parameter is missing. Without
strict mode, missing named parameters do not throw.
## Writes and Transactions
```typescript
const result = db.query("INSERT INTO users (name) VALUES (?)").run("Alice");
console.log(result.lastInsertRowid, result.changes);
```
```typescript
const insert = db.prepare("INSERT INTO users (name) VALUES ($name)");
const insertMany = db.transaction((users: { $name: string }[]) => {
for (const user of users) insert.run(user);
return users.length;
});
insertMany([{ $name: "Alice" }, { $name: "Bob" }]);
insertMany.immediate([{ $name: "Carol" }]);
insertMany.exclusive([{ $name: "Dana" }]);
```
## Mapping and Cleanup
```typescript
class User {
id!: number;
name!: string;
get displayName() {
return `User: ${this.name}`;
}
}
const users = db.query("SELECT * FROM users").as(User).all();
db.close();
```
## Guardrails
- All database operations are synchronous. Keep heavy queries out of latency-sensitive
request paths or isolate them in workers/processes.
- Do not concatenate SQL. Bind parameters.
- Choose `strict: true` deliberately; it changes named parameter object keys.
- Choose `safeIntegers: true` deliberately when SQLite integers may exceed
JavaScript's safe number range.
- Close databases owned by the current process, especially in tests.
- Test transaction behavior with a real database or `:memory:`, not mocked statements.
## Official Docs
- `https://bun.sh/docs/runtime/sqlite`
references/testing.md
# Testing with bun:test
Bun's test runner is TypeScript-first and Jest-compatible enough for most CLI and
library tests. Prefer direct unit tests for pure functions and subprocess tests for
real CLI behavior.
## Core API
```typescript
import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
describe("processData", () => {
test("trims and uppercases input", () => {
expect(processData(" hello ")).toBe("HELLO");
});
});
```
Common matchers: `toBe`, `toEqual`, `toStrictEqual`, `toContain`, `toMatch`,
`toMatchObject`, `toHaveProperty`, `toThrow`, `resolves`, `rejects`,
`toMatchSnapshot`, `toMatchInlineSnapshot`, plus mock call matchers such as
`toHaveBeenCalled` and `toHaveBeenCalledTimes`.
## Test Selection and Execution
```typescript
test.skip("documents skipped behavior", () => {});
test.only("focuses one test while debugging", () => {});
test.todo("covers config migration");
test.if(process.platform !== "win32")("uses POSIX permissions", () => {});
test.concurrent("parallel-safe case", async () => {});
test.serial("shared state case", async () => {});
```
Useful CLI flags:
```bash
bun test
bun test path/or/name
bun test --test-name-pattern "config"
bun test --concurrent --max-concurrency 4
bun test --isolate
bun test --parallel
bun test --parallel=4
bun test --shard=1/3
bun test --changed
bun test --changed=main
bun test --rerun-each 50
bun test --retry 2
bun test --randomize
bun test --seed 12345
bun test --bail=1
bun test --reporter=junit --reporter-outfile=./bun.xml
bun test --coverage
```
Use `--rerun-each`, `--randomize`, or `--seed` when chasing order-dependent or
flaky tests. `--seed` implies `--randomize`. Use `test.serial` for shared state
that cannot be isolated cheaply, and `--isolate`/`--parallel` for larger suites
where file-level isolation and worker processes help.
## Parameterized and Async Tests
```typescript
test.each([
[" abc ", "ABC"],
["x", "X"],
])("processData(%j) returns %j", (input, expected) => {
expect(processData(input)).toBe(expected);
});
test("rejects bad input", async () => {
await expect(loadConfig("missing.json")).rejects.toThrow("missing.json");
});
```
## Mocks, Spies, and Modules
```typescript
const fetchUser = mock(async (id: string) => ({ id }));
test("calls dependency", async () => {
await fetchUser("42");
expect(fetchUser).toHaveBeenCalledWith("42");
});
```
```typescript
const spy = spyOn(console, "error").mockImplementation(() => {});
try {
logFailure(new Error("boom"));
expect(spy).toHaveBeenCalled();
} finally {
spy.mockRestore();
}
```
Module mocks are hoisted by Bun and can override already-imported modules:
```typescript
mock.module("./github", () => ({
fetchRelease: mock(async () => ({ tag_name: "v1.0.0" })),
}));
```
Prefer dependency injection for new code. Use `mock.module()` when replacing an
imported external boundary is simpler than reshaping production code.
## CLI Integration Tests
Run the real entrypoint in a temp directory and assert exit code plus output.
```typescript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
describe("my-script CLI", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "my-script-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
test("prints processed file content", async () => {
const input = join(tempDir, "input.txt");
await writeFile(input, "hello");
const proc = Bun.spawn(["bun", "./my-script.ts", input], {
cwd: import.meta.dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(stdout).toContain("HELLO");
});
});
```
Guidelines:
- Test pure exported functions directly.
- Use subprocess tests for argument parsing, exit codes, stdio, and filesystem effects.
- Use temp directories for file tests and `force: true` in cleanup.
- Capture stderr in negative-path tests; do not rely only on exit code.
- Avoid snapshotting noisy CLI output unless the command is intentionally stable.
## Official Docs
- `https://bun.sh/docs/test`
- `https://bun.sh/docs/test/mocks`
scripts/probe-bun-mcp.sh
#!/usr/bin/env bash
# shellcheck disable=SC2310,SC2312
# Probes the current project for the official Bun docs MCP server (bun-docs).
# Called via !`command` preprocessing when the skill loads.
# Outputs XML to <bun-mcp-status>.
set -euo pipefail
# --- Dependency checks (early exit with useful output) ---
for dep in jq git; do
command -v "${dep}" >/dev/null 2>&1 || {
printf '<bun-mcp-status error="missing dependency: %s">\n' "${dep}"
printf ' <action>Install %s to enable Bun MCP detection.</action>\n' "${dep}"
printf '</bun-mcp-status>\n'
exit 0
}
done
# --- Locate project root and .mcp.json ---
project_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
mcp_file="${project_root}/.mcp.json"
# --- Helpers ---
# Indent each line of stdin by N spaces.
indent() {
local n="${1:-0}"
local pad
pad=$(printf '%*s' "${n}" '')
sed "s/^/${pad}/"
}
# The JSON block to inject into .mcp.json (canonical, no extra indentation).
mcp_bun_docs_json() {
cat <<-'JSON'
"bun-docs": {
"type": "http",
"url": "https://bun.com/docs/mcp"
}
JSON
}
# --- Case 1: No .mcp.json at all ---
if [[ ! -f "${mcp_file}" ]]; then
cat <<XML
<bun-mcp-status installed="false" mcp-file="missing">
<action>
Bun docs MCP server is not configured (no .mcp.json found at project root).
The Bun docs MCP server is optional. Before editing project config, ask the
user whether to add it:
"The Bun docs MCP server is not configured in this project. Want me to create
.mcp.json? You will need to reload plugins/tools after."
If the user accepts:
1. Read ${mcp_file} first (it may already exist with other servers).
2. If the file exists, merge this entry into the existing mcpServers object.
If it does not exist, create it with this content:
{
"mcpServers": {
$(mcp_bun_docs_json | indent 10)
}
}
3. Tell the user to run /reload-plugins to activate the MCP server.
4. After reload, the official Bun docs tools become available: search_bun,
read_bun_page, list_bun_pages, grep_bun.
If the user declines:
Use official Bun docs/Context7/exa for documentation lookups, and Bash for
running bun commands directly.
</action>
</bun-mcp-status>
XML
exit 0
fi
# --- Case 2: .mcp.json exists, check for "bun-docs" entry ---
has_bun=$(jq -r '.mcpServers["bun-docs"] // empty' "${mcp_file}" 2>/dev/null) || true
if [[ -n "${has_bun}" ]]; then
cat <<'XML'
<bun-mcp-status installed="true">
<guidance>
Bun docs MCP server is available. Prefer its official documentation tools
over fetching pages by hand:
- Search the docs: search_bun
- Read a page: read_bun_page
- List pages under a path: list_bun_pages
- Exact keyword/regex search: grep_bun
Run bun commands directly with Bash; fall back to Context7/exa for lookups
not covered by these tools.
</guidance>
</bun-mcp-status>
XML
else
cat <<XML
<bun-mcp-status installed="false" mcp-file="exists">
<action>
.mcp.json exists but has no "bun-docs" MCP server entry.
The Bun docs MCP server is optional. Before editing project config, ask the
user whether to add it:
"The Bun docs MCP server is not configured in this project's .mcp.json.
Want me to add it? You will need to reload plugins/tools after."
If the user accepts:
1. Read ${mcp_file}, add "bun-docs" to the mcpServers object:
$(mcp_bun_docs_json | indent 7)
2. Write the updated JSON back to ${mcp_file}.
3. Tell the user to run /reload-plugins to activate the MCP server.
If the user declines:
Use official Bun docs/Context7/exa for documentation lookups, and Bash for
running bun commands directly.
</action>
</bun-mcp-status>
XML
fi
SKILL.md
---
name: bun-cc
description: >
TypeScript CLI scripts and applications using the Bun runtime. Covers file structure
conventions, Bun Shell ($), File I/O, testing with bun:test, HTTP servers, SQLite, and
bundling. NOT for: Node.js/Deno projects, frontend-only React/Vue apps, or npm package
publishing workflows.
model: opus
effort: medium
allowed-tools:
- Read
- Write
- Edit
- Grep
- Glob
- Bash
- WebFetch
- WebSearch
- mcp__bun-docs__search_bun
- mcp__bun-docs__read_bun_page
- mcp__bun-docs__list_bun_pages
- mcp__bun-docs__grep_bun
- mcp__Context7__resolve-library-id
- mcp__Context7__query-docs
- mcp__exa__web_search_exa
- mcp__exa__get_code_context_exa
license: "MIT. Copyright (c) 2026 Augustin BENGOLEA. Full terms in LICENSE at https://github.com/bengous/agents-skills"
---
# Bun Development
Use this skill for Bun runtime scripts, CLIs, small services, and Bun-native tooling.
Prefer project conventions over these defaults when the local repo already has a
working Bun shape.
## Navigating References
Load only the reference needed for the task.
| File | Answers |
|---|---|
| `references/bun-api.md` | Shell `$`, spawn, file I/O, `Bun.Glob`, archives, utilities |
| `references/testing.md` | `bun:test`, mocks, CLI integration tests, CI flags |
| `references/patterns.md` | CLI logging, confirmation, errors, parallel work |
| `references/http-server.md` | `Bun.serve`, routing, WebSocket upgrades |
| `references/bundler.md` | `Bun.build`, standalone executables, targets |
| `references/sqlite.md` | `bun:sqlite`, prepared statements, transactions |
| `references/crypto.md` | Bun.password hashing, CryptoHasher |
## Task Workflow
1. Inspect local truth first: `package.json`, `bunfig.toml`, `tsconfig.json`, lockfile,
scripts, current Bun version, and existing test/build commands.
2. Use the Bun docs MCP tools (`mcp__bun-docs__*`) for official documentation
lookups when they are loaded. Run Bun commands (run/test/build/install) with
Bash; fall back to Context7/exa or fetching official docs for API uncertainty.
3. For Bun API changes, verify the current release with official Bun docs and source
before updating guidance; Bun APIs move quickly.
4. Keep root code plain TypeScript unless the project already uses a framework. Bun is
the runtime/toolkit, not an excuse to add abstractions.
5. Export pure functions from CLI entrypoints for direct tests; keep side effects in
`main()` or thin adapters.
6. Run the smallest project-native validation first (`bun test`, targeted tests,
typecheck/lint/build as configured), then broaden only when the change touches shared
behavior.
## Default CLI Shape
Use this shape when the project has no stronger convention:
```
~/.local/bin/
|-- my-script # Bash wrapper in PATH
`-- my-script-lib/
|-- my-script.ts # TypeScript entrypoint
|-- my-script.test.ts
|-- package.json
`-- tsconfig.json
```
```bash
#!/usr/bin/env bash
exec bun "$HOME/.local/bin/my-script-lib/my-script.ts" "$@"
```
This keeps the command name extensionless while leaving TypeScript and tests in a
normal project directory.
## Quick Start Template
```typescript
#!/usr/bin/env bun
const VERSION = "1.0.0";
export function processData(input: string): string {
return input.trim().toUpperCase();
}
async function main(): Promise<void> {
const args = Bun.argv.slice(2);
if (args.includes("--version")) {
console.log(VERSION);
process.exit(0);
}
if (args.length === 0) {
console.error("Usage: my-script <input>");
process.exit(1);
}
const result = processData(args[0]);
console.log(result);
}
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
```
```json
{
"name": "my-script",
"version": "1.0.0",
"type": "module",
"scripts": { "test": "bun test" },
"devDependencies": { "@types/bun": "latest" }
}
```
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"types": ["bun"],
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"noEmit": true
}
}
```
## Gotchas
- `Bun.file().exists()` is async. Always `await` it.
- Bun transpiles TypeScript but does not typecheck. Use the project typecheck command.
- Install Bun globals with `@types/bun`; do not use stale `bun-types` tsconfig entries.
- Bun Shell interpolation is escaped by default, but protection ends when handing
data to another shell such as `bash -c`.
- Bun Shell does not prevent argument injection into tools that interpret a safely
passed string as a flag. Validate untrusted arguments before passing them to commands.
- `Bun.build()` rejects on failure by default. Use `try/catch`, or set `throw: false`
only when you intend to inspect `result.success` and `result.logs`.
- Use `node:fs/promises` for directory operations not covered by `Bun.file` or
`Bun.write`.
- Prefer absolute paths or `Bun.which()` when invoking a security-sensitive binary.
## Fetching Documentation
Use official Bun docs when API details matter:
| Need | Source |
|---|---|
| Current releases | `https://github.com/oven-sh/bun/releases` |
| Shell | `https://bun.sh/docs/runtime/shell` |
| Spawn | `https://bun.sh/docs/runtime/child-process` |
| File I/O | `https://bun.sh/docs/runtime/file-io` |
| Glob | `https://bun.sh/docs/runtime/glob` |
| Archive | `https://bun.sh/docs/runtime/archive` |
| Test runner | `https://bun.sh/docs/test` |
| HTTP server | `https://bun.sh/docs/runtime/http/server` |
| Routing | `https://bun.sh/docs/runtime/http/routing` |
| WebSockets | `https://bun.sh/docs/runtime/http/websockets` |
| Bundler | `https://bun.sh/docs/bundler` |
| Executables | `https://bun.sh/docs/bundler/executables` |
| TypeScript config | `https://bun.sh/docs/typescript` |
| Package manager/install | `https://bun.sh/docs/pm` |
!`echo "## Bun MCP"`
!`~/.agents/skills/bun-cc/scripts/probe-bun-mcp.sh`