Bunli
Packages

@bunli/utils

Shared utilities for CLI development

Installation

bash bun add @bunli/utils

Colors

Terminal colors with automatic detection and fallback.

Basic Colors

import { colors } from "@bunli/utils";

// Basic colors
console.log(colors.red("Error!"));
console.log(colors.green("Success!"));
console.log(colors.yellow("Warning!"));
console.log(colors.blue("Info"));
console.log(colors.magenta("Debug"));
console.log(colors.cyan("Note"));
console.log(colors.gray("Disabled"));

// Bright colors
console.log(colors.brightRed("Critical error!"));
console.log(colors.brightGreen("Great success!"));
console.log(colors.brightYellow("Important warning!"));

Text Styles

// Modifiers
console.log(colors.bold("Bold text"));
console.log(colors.dim("Dimmed text"));
console.log(colors.italic("Italic text"));
console.log(colors.underline("Underlined"));
console.log(colors.strikethrough("Strikethrough"));

// Strip ANSI codes
const colored = colors.red("Error!");
const plain = colors.strip(colored); // 'Error!'

Background Colors

console.log(colors.bgRed("Error background"));
console.log(colors.bgGreen("Success background"));
console.log(colors.bgYellow.black("Warning"));

Color Detection

// Colors automatically disabled when:
// - Not in TTY environment (process.stdout.isTTY === false)
// - NO_COLOR environment variable is set
// - CI environment detected

const message = colors.red("Error!"); // Returns plain 'Error!' if colors disabled

XDG Directory Paths

Platform-standard directories following the XDG base directory specification.

import { configDir, dataDir, stateDir, cacheDir } from "@bunli/utils";

configDir

Returns the platform-standard config directory:

  • Linux/macOS: $XDG_CONFIG_HOME/<appName> or ~/.config/<appName>
  • Windows: %APPDATA%/<appName> or ~/AppData/Roaming/<appName>
const dir = configDir("my-cli");
// Linux: /home/user/.config/my-cli
// macOS: /Users/user/.config/my-cli
// Windows: C:\Users\user\AppData\Roaming\my-cli

dataDir

Returns the platform-standard data directory:

  • Linux/macOS: $XDG_DATA_HOME/<appName> or ~/.local/share/<appName>
  • Windows: %LOCALAPPDATA%/<appName>/Data
const dir = dataDir("my-cli");
// Linux: /home/user/.local/share/my-cli

stateDir

Returns the platform-standard state directory:

  • Linux/macOS: $XDG_STATE_HOME/<appName> or ~/.local/state/<appName>
  • Windows: %LOCALAPPDATA%/<appName>/State
const dir = stateDir("my-cli");
// Linux: /home/user/.local/state/my-cli

cacheDir

Returns the platform-standard cache directory:

  • Linux/macOS: $XDG_CACHE_HOME/<appName> or ~/.cache/<appName>
  • Windows: %LOCALAPPDATA%/<appName>/Cache
const dir = cacheDir("my-cli");
// Linux: /home/user/.cache/my-cli

Testing with PlatformEnv

All functions accept an optional PlatformEnv parameter for deterministic testing:

import { configDir, type PlatformEnv } from "@bunli/utils";

const testEnv: PlatformEnv = {
  platform: "linux",
  env: { XDG_CONFIG_HOME: "/test/config" },
  homedir: "/test/home",
};

const dir = configDir("my-cli", testEnv);
// Returns: /test/config/my-cli

Log Utilities

Structured logging to stderr with level, timestamp, and field support.

import { formatLog, log, type LogLevel, type LogOptions } from "@bunli/utils";

formatLog

Format a log message and return an ANSI-styled string:

const msg = formatLog("Server started", { level: "info" });
// Returns: 'INFO  Server started'

const msg = formatLog("Connection failed", {
  level: "error",
  timestamp: true,
  prefix: "db",
  fields: { host: "localhost", port: 5432 },
});
// Returns: '2024-01-15 10:30:00 ERROR db Connection failed  host=localhost  port=5432'

LogOptions:

OptionTypeDefaultDescription
levelLogLevel'info'Log level: 'debug' | 'info' | 'warn' | 'error' | 'fatal'
timestampbooleanfalseInclude timestamp
timestampFormat'iso' | 'short' | 'time''short'Timestamp format
prefixstringPrefix shown after level
separatorstring'='Separator between field key and value
fieldsRecord<string, string | number | boolean>Structured fields to append

log

Write a formatted log message directly to stderr:

log("Build complete", { level: "info", timestamp: true });
// Outputs to stderr: '2024-01-15 10:30:00 INFO  Build complete\n'

Shell Integration

Utilities for shell command composition and stdin/stdout handling.

import { EXIT_CODES, readStdinLines, stripAnsi, writeStdout, writeStdoutLines } from "@bunli/utils";

readStdinLines

Read lines from stdin when piped (non-TTY). Returns empty array if stdin is a TTY.

// Piped input: echo -e "line1\nline2" | bun run script.ts
const lines = await readStdinLines();
// Returns: ['line1', 'line2']

// With custom delimiter
const paragraphs = await readStdinLines("\n\n");

writeStdout

Write to stdout, automatically stripping ANSI codes when piping to non-TTY:

writeStdout(colors.green("Success!"));
// TTY: outputs green text
// Pipe: outputs plain 'Success!'

writeStdoutLines

Write multiple values to stdout, one per line:

writeStdoutLines(["item1", "item2", "item3"]);

stripAnsi

Remove ANSI escape codes from a string:

const plain = stripAnsi("\x1b[32mSuccess!\x1b[0m");
// Returns: 'Success!'

EXIT_CODES

Conventional exit codes matching gum conventions:

import { EXIT_CODES } from "@bunli/utils";

process.exit(EXIT_CODES.SUCCESS); // 0
process.exit(EXIT_CODES.CANCEL); // 1
process.exit(EXIT_CODES.TIMEOUT); // 124
process.exit(EXIT_CODES.SIGINT); // 130

Validation

Schema validation helpers that work with Standard Schema v1 libraries (Zod, Valibot, etc.):

import { validate, validateFields, SchemaError } from "@bunli/utils";
import { z } from "zod";

validate

Validate a single value against a schema, returning a Result:

const schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().positive(),
});

const result = await validate(schema, { name: "Alice", email: "alice@example.com", age: 30 });

if (result.ok) {
  console.log(result.value); // Fully typed and validated
} else {
  console.error(result.error); // SchemaError with issues
}

validateFields

Validate multiple fields at once with aggregated errors:

const schemas = {
  name: z.string().min(1),
  email: z.string().email(),
  port: z.number().int().positive(),
};

const result = await validateFields(schemas, {
  name: "John",
  email: "invalid-email",
  port: -1,
});

if (result.ok) {
  console.log(result.value.name); // 'John'
  console.log(result.value.port); // typed as number
} else {
  // result.error is Record<string, string[]>
  for (const [field, errors] of Object.entries(result.error)) {
    console.error(`${field}: ${errors.join(", ")}`);
  }
}

SchemaError

Error class for validation failures:

import { SchemaError } from "@bunli/utils";
// Also re-exported from @standard-schema/utils

const result = await validate(schema, invalidValue);
if (result.error instanceof SchemaError) {
  console.log(result.error.issues);
  // [{ path: ['email'], message: 'Invalid email' }]
}

getDotPath

Re-exported from @standard-schema/utils. Extracts the dot-notation path string from a Standard Schema validation issue:

import { getDotPath } from "@bunli/utils";

// Given a validation issue with path segments
const issue = { message: "Invalid email", path: ["user", "profile", "email"] };
getDotPath(issue); // 'user.profile.email'

// Issue without a path
const rootIssue = { message: "Invalid input" };
getDotPath(rootIssue); // undefined

// Useful for formatting validation error messages
const result = await validate(schema, input);
if (!result.ok) {
  for (const issue of result.error.issues) {
    const path = getDotPath(issue);
    console.error(`${path ?? "(root)"}: ${issue.message}`);
  }
}

Also exported from @standard-schema/utils directly:

import { getDotPath } from "@standard-schema/utils";

Best Practices

  1. Use appropriate log levels:

    log("Debug info", { level: "debug" });
    log("Server started", { level: "info" });
    log("Connection failed", { level: "error" });
  2. Use XDG directories for user data:

    const configPath = configDir("my-cli");
    const dataPath = dataDir("my-cli");
  3. Handle shell stdin/stdout correctly:

    // Read piped input
    const lines = await readStdinLines();
    if (lines.length === 0) {
      // No piped input - read from interactive prompt
    }
  4. Use structured logging in production:

    log("Request processed", {
      level: "info",
      timestamp: true,
      fields: { method: "GET", status: 200, duration: 45 },
    });
  5. Use colors for terminal output, not logging:

    // Good: colors for terminal display
    console.log(colors.green("✓") + " Done");
    
    // Use log() for structured logging to stderr
    log("Task completed", { level: "info" });

All utilities respect NO_COLOR and CI environment variables, automatically adjusting their behavior for different environments.

On this page