📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
@@ -0,0 +1,359 @@
# CLI Patterns
Deep dive into @outfitter/cli patterns.
## Creating a CLI
```typescript
import { createCLI } from "@outfitter/cli";
const cli = createCLI({
name: "myapp",
version: "1.0.0",
description: "My CLI application",
});
cli.program.addCommand(listCommand);
cli.program.addCommand(getCommand);
cli.program.parse();
```
## Command Builder
Type-safe command construction:
```typescript
import { command } from "@outfitter/cli";
export const myCommand = command("my-command")
.description("What this command does")
.argument("<id>", "Required resource ID")
.argument("[name]", "Optional name")
.option("-l, --limit <n>", "Limit results", parseInt)
.option("-v, --verbose", "Enable verbose output")
.option("-t, --tags <tags...>", "Filter by tags")
.action(async ({ args, flags }) => {
// args.id: string
// args.name: string | undefined
// flags.limit: number | undefined
// flags.verbose: boolean
// flags.tags: string[] | undefined
})
.build();
```
## Output Modes
### Automatic Detection
```typescript
import { output } from "@outfitter/cli";
await output(data); // Human for TTY, JSON for pipes
```
### Mode Priority
1. Explicit `mode` option
2. `OUTFITTER_JSONL=1` env var
3. `OUTFITTER_JSON=1` env var
4. `OUTFITTER_JSON=0` forces human
5. TTY detection fallback
### Forcing Modes
```typescript
// Force JSON
await output(data, { mode: "json" });
// Force human
await output(data, { mode: "human" });
// JSONL for streaming
for await (const item of items) {
await output(item, { mode: "jsonl" });
}
// Output to stderr
await output(errorData, { stream: process.stderr });
```
### Custom Formatters
```typescript
await output(data, {
formatters: {
human: (data) => formatTable(data),
json: (data) => JSON.stringify(data, null, 2),
},
});
```
## Error Handling
### Exit with Error
```typescript
import { exitWithError } from "@outfitter/cli";
const result = await handler(input, ctx);
if (result.isErr()) {
exitWithError(result.error); // Exit code from error category
}
```
### Exit Code Mapping
| Category | Exit Code |
|----------|-----------|
| validation | 1 |
| not_found | 2 |
| conflict | 3 |
| permission | 4 |
| timeout | 5 |
| rate_limit | 6 |
| network | 7 |
| internal | 8 |
| auth | 9 |
| cancelled | 130 |
### Custom Error Output
```typescript
import { formatError, getExitCode } from "@outfitter/cli";
if (result.isErr()) {
const formatted = formatError(result.error, { verbose: flags.verbose });
await output(formatted, { stream: process.stderr });
process.exit(getExitCode(result.error.category));
}
```
## Pagination
### Cursor State
Cursors persist in XDG state directory:
```
$XDG_STATE_HOME/{toolName}/cursors/{command}/cursor.json
```
### Using Pagination
```typescript
import { loadCursor, saveCursor, clearCursor } from "@outfitter/cli";
const options = { command: "list", toolName: "myapp" };
// Load previous cursor
const state = loadCursor(options);
// Fetch data with cursor
const results = await listItems({
cursor: state?.cursor,
limit: 20,
});
// Save for --next
if (results.hasMore) {
saveCursor(results.nextCursor, options);
}
// Clear on --reset
if (flags.reset) {
clearCursor(options);
}
```
### Cursor Expiration
```typescript
const state = loadCursor({
...options,
maxAgeMs: 30 * 60 * 1000, // 30 minutes
});
```
### Pagination Command Pattern
```typescript
export const listCommand = command("list")
.option("-n, --next", "Continue from previous position")
.option("--reset", "Reset pagination cursor")
.option("-l, --limit <n>", "Results per page", parseInt, 20)
.action(async ({ flags }) => {
const paginationOpts = { command: "list", toolName: "myapp" };
if (flags.reset) {
clearCursor(paginationOpts);
console.log("Cursor reset");
return;
}
const cursor = flags.next ? loadCursor(paginationOpts)?.cursor : undefined;
const result = await listHandler({ cursor, limit: flags.limit }, ctx);
if (result.isErr()) {
exitWithError(result.error);
}
await output(result.value.items);
if (result.value.nextCursor) {
saveCursor(result.value.nextCursor, paginationOpts);
console.log("\nUse --next for more results");
}
})
.build();
```
## Input Parsing
### Stdin Reading
```typescript
import { readStdin } from "@outfitter/cli";
const input = await readStdin(); // Returns string or null if no stdin
```
### Piped Detection
```typescript
import { isPiped } from "@outfitter/cli";
if (isPiped()) {
const data = await readStdin();
} else {
// Interactive mode
}
```
## Progress Indicators
> **Note:** UI components merged into `@outfitter/cli`. Import from `@outfitter/cli` directly.
```typescript
import { createSpinner, createProgressBar } from "@outfitter/cli";
// Spinner
const spinner = createSpinner("Loading...");
spinner.start();
// ... work
spinner.succeed("Done!");
// Progress bar
const progress = createProgressBar({ total: 100 });
for (let i = 0; i <= 100; i++) {
progress.update(i);
}
progress.stop();
```
## Formatting Utilities
### Date Range Parsing
Parse human-readable date ranges:
```typescript
import { parseDateRange } from "@outfitter/cli";
const range = parseDateRange("last 7 days");
// { start: Date, end: Date }
const range2 = parseDateRange("2026-01-01..2026-01-31");
// { start: Date, end: Date }
// Supported formats:
// - "last N days/weeks/months"
// - "today", "yesterday", "this week", "this month"
// - "YYYY-MM-DD..YYYY-MM-DD" (range)
// - "YYYY-MM-DD" (single day)
```
### Duration Formatting
Format milliseconds as human-readable duration:
```typescript
import { formatDuration } from "@outfitter/cli";
formatDuration(1500); // "1.5s"
formatDuration(65000); // "1m 5s"
formatDuration(3661000); // "1h 1m 1s"
formatDuration(90061000); // "1d 1h 1m"
```
### Byte Formatting
Format bytes as human-readable sizes:
```typescript
import { formatBytes } from "@outfitter/cli";
formatBytes(1024); // "1 KB"
formatBytes(1536); // "1.5 KB"
formatBytes(1048576); // "1 MB"
formatBytes(1073741824); // "1 GB"
```
### Pluralization
Pluralize words based on count:
```typescript
import { pluralize } from "@outfitter/cli";
pluralize(1, "file"); // "1 file"
pluralize(5, "file"); // "5 files"
pluralize(0, "item"); // "0 items"
// Custom plural form
pluralize(2, "person", "people"); // "2 people"
```
### Slugification
Convert strings to URL-safe slugs:
```typescript
import { slugify } from "@outfitter/cli";
slugify("Hello World"); // "hello-world"
slugify("My New Feature!"); // "my-new-feature"
slugify("Café Résumé"); // "cafe-resume"
```
### Custom Renderers
Register custom output renderers for specific data types:
```typescript
import { registerRenderer, output } from "@outfitter/cli";
interface User {
id: string;
name: string;
email: string;
}
registerRenderer<User>("user", {
human: (user) => `${user.name} <${user.email}>`,
json: (user) => JSON.stringify(user),
});
// Now output() will use your renderer when type matches
await output(user, { type: "user" });
```
## Best Practices
1. **Handler first** - Business logic in handler, CLI is thin adapter
2. **Output modes** - Support both human and JSON output
3. **Exit codes** - Use `exitWithError` for consistent codes
4. **Pagination** - Use cursor state for `--next` functionality
5. **Stdin support** - Handle piped input gracefully
6. **TTY detection** - Adapt behavior for interactive vs piped
@@ -0,0 +1,211 @@
# Conversion Patterns
Patterns for converting existing code to Outfitter Stack conventions.
## Exceptions to Result
Convert throw-based error handling to Result types.
**Before:**
```typescript
async function getUser(id: string): Promise<User> {
const user = await db.users.findById(id);
if (!user) throw new Error(`Not found: ${id}`);
return user;
}
```
**After:**
```typescript
import { Result, NotFoundError, type Handler } from "@outfitter/contracts";
const getUser: Handler<{ id: string }, User, NotFoundError> = async (input, ctx) => {
const user = await db.users.findById(input.id);
if (!user) return Result.err(new NotFoundError("user", input.id));
return Result.ok(user);
};
```
## Console to Structured Logging
Replace console calls with structured logging via context.
**Before:**
```typescript
console.log("Processing", userId);
console.error("Failed to process", error);
console.warn("Deprecated API usage");
```
**After:**
```typescript
ctx.logger.info("Processing", { userId });
ctx.logger.error("Failed to process", { error: error.message });
ctx.logger.warn("Deprecated API usage", { api: "oldEndpoint" });
```
### Logging Level Mapping
| Console Method | Logger Method | When to Use |
|----------------|---------------|-------------|
| `console.log` | `ctx.logger.info` | Normal operations |
| `console.debug` | `ctx.logger.debug` | Development debugging |
| `console.warn` | `ctx.logger.warn` | Unexpected but handled |
| `console.error` | `ctx.logger.error` | Failures requiring attention |
## Hardcoded Paths to XDG
Replace hardcoded home directory paths with XDG-compliant paths.
**Before:**
```typescript
import os from "node:os";
import path from "node:path";
const configPath = path.join(os.homedir(), ".myapp", "config.json");
const cachePath = path.join(os.homedir(), ".cache", "myapp");
const dataPath = path.join(os.homedir(), ".local", "share", "myapp");
```
**After:**
```typescript
import { getConfigDir, getCacheDir, getDataDir } from "@outfitter/config";
import path from "node:path";
const configPath = path.join(getConfigDir("myapp"), "config.json");
const cachePath = getCacheDir("myapp");
const dataPath = getDataDir("myapp");
```
### XDG Path Functions
| Function | Default Path | Env Override |
|----------|--------------|--------------|
| `getConfigDir(app)` | `~/.config/{app}` | `XDG_CONFIG_HOME` |
| `getCacheDir(app)` | `~/.cache/{app}` | `XDG_CACHE_HOME` |
| `getDataDir(app)` | `~/.local/share/{app}` | `XDG_DATA_HOME` |
| `getStateDir(app)` | `~/.local/state/{app}` | `XDG_STATE_HOME` |
## Error Taxonomy Mapping
Map existing custom errors to the 10 taxonomy categories.
| Original Pattern | Outfitter Error | Category |
|------------------|-----------------|----------|
| `NotFoundError` | `NotFoundError` | `not_found` |
| `InvalidInputError` | `ValidationError` | `validation` |
| `DuplicateError` | `ConflictError` | `conflict` |
| `UnauthorizedError` | `AuthError` | `auth` |
| `ForbiddenError` | `PermissionError` | `permission` |
| `TimeoutError` | `TimeoutError` | `timeout` |
| `RateLimitError` | `RateLimitError` | `rate_limit` |
| `ConnectionError` | `NetworkError` | `network` |
| Generic `Error` | `InternalError` | `internal` |
| `AbortError` | `CancelledError` | `cancelled` |
### Mapping by Error Name Keywords
| Keyword in Error Name | Maps To |
|-----------------------|---------|
| `notfound`, `missing` | `NotFoundError` |
| `validation`, `invalid`, `input` | `ValidationError` |
| `conflict`, `duplicate`, `exists` | `ConflictError` |
| `permission`, `forbidden` | `PermissionError` |
| `timeout` | `TimeoutError` |
| `ratelimit`, `rate`, `throttle` | `RateLimitError` |
| `network`, `connection` | `NetworkError` |
| `auth`, `unauthorized`, `unauthenticated` | `AuthError` |
| `cancel`, `abort` | `CancelledError` |
## Compatibility Layer
Wrap legacy throwing code during transition with a Result-returning wrapper.
```typescript
import { Result, InternalError } from "@outfitter/contracts";
/**
* Wraps a synchronous function that may throw, returning a Result.
*/
function wrapSync<T>(fn: () => T): Result<T, InternalError> {
try {
return Result.ok(fn());
} catch (error) {
return Result.err(new InternalError(
error instanceof Error ? error.message : "Unknown error",
{ cause: error }
));
}
}
/**
* Wraps an async function that may throw, returning a Result.
*/
async function wrapAsync<T>(fn: () => Promise<T>): Promise<Result<T, InternalError>> {
try {
return Result.ok(await fn());
} catch (error) {
return Result.err(new InternalError(
error instanceof Error ? error.message : "Unknown error",
{ cause: error }
));
}
}
```
### Usage Example
```typescript
// Wrap a third-party library call
const result = await wrapAsync(() => thirdPartyApi.fetch(id));
if (result.isErr()) {
ctx.logger.error("Third-party API failed", { error: result.error });
return result;
}
const data = result.value;
```
## Try-Catch to Result
Convert try-catch blocks to Result chains.
**Before:**
```typescript
async function processOrder(orderId: string): Promise<Order> {
try {
const order = await fetchOrder(orderId);
const validated = validateOrder(order);
const processed = await processPayment(validated);
return processed;
} catch (error) {
console.error("Order processing failed", error);
throw error;
}
}
```
**After:**
```typescript
const processOrder: Handler<{ orderId: string }, Order, OrderError> = async (input, ctx) => {
const orderResult = await fetchOrder(input.orderId, ctx);
if (orderResult.isErr()) return orderResult;
const validatedResult = validateOrder(orderResult.value);
if (validatedResult.isErr()) return validatedResult;
const processedResult = await processPayment(validatedResult.value, ctx);
if (processedResult.isErr()) return processedResult;
return Result.ok(processedResult.value);
};
```
## Conversion Strategy
1. **New code first** - All new code uses stack patterns
2. **Leaf functions** - Start with functions that don't call others
3. **Bottom-up** - Convert dependencies before dependents
4. **Feature boundaries** - Complete one feature at a time
5. **Test coverage** - Add tests before converting
@@ -0,0 +1,300 @@
# Daemon Patterns
Deep dive into @outfitter/daemon patterns.
## Creating a Daemon
```typescript
import {
createDaemon,
getLockPath,
} from "@outfitter/daemon";
import { createLogger, createConsoleSink } from "@outfitter/logging";
const logger = createLogger({
name: "my-daemon",
level: "info",
sinks: [createConsoleSink()],
});
const daemon = createDaemon({
name: "my-daemon",
pidFile: getLockPath("my-daemon"),
logger,
shutdownTimeout: 10000, // 10s graceful shutdown
});
```
## Lifecycle Hooks
```typescript
// Called before start
daemon.onBeforeStart(async () => {
logger.info("Preparing to start...");
await initializeDatabase();
});
// Called after start
daemon.onAfterStart(async () => {
logger.info("Daemon started successfully");
});
// Called on shutdown (SIGTERM, SIGINT)
daemon.onShutdown(async () => {
logger.info("Shutting down...");
await closeConnections();
await flushBuffers();
});
// Start the daemon
const result = await daemon.start();
if (result.isErr()) {
logger.error("Failed to start", { error: result.error });
process.exit(1);
}
```
## IPC Server
### Setting Up IPC
```typescript
import {
createIpcServer,
getSocketPath,
} from "@outfitter/daemon";
const ipcServer = createIpcServer(getSocketPath("my-daemon"));
// Handle messages
ipcServer.onMessage(async (msg) => {
const message = msg as { type: string; payload?: unknown };
switch (message.type) {
case "status":
return {
status: "ok",
uptime: process.uptime(),
version: "1.0.0",
};
case "reload":
await reloadConfig();
return { success: true };
case "metrics":
return getMetrics();
default:
return { error: "Unknown command" };
}
});
// Register cleanup
daemon.onShutdown(async () => {
await ipcServer.close();
});
// Start listening
await ipcServer.listen();
logger.info("IPC listening", { socket: getSocketPath("my-daemon") });
```
### IPC Client
```typescript
import {
createIpcClient,
getSocketPath,
} from "@outfitter/daemon";
const client = createIpcClient(getSocketPath("my-daemon"));
await client.connect();
// Send message and get response
const status = await client.send<{
status: string;
uptime: number;
}>({ type: "status" });
console.log("Daemon status:", status);
// Clean up
client.close();
```
## Health Checks
### Defining Checks
```typescript
import { createHealthChecker } from "@outfitter/daemon";
import { Result } from "@outfitter/contracts";
const healthChecker = createHealthChecker([
{
name: "memory",
check: async () => {
const used = process.memoryUsage().heapUsed / 1024 / 1024;
return used < 500
? Result.ok(undefined)
: Result.err(new Error(`High memory: ${used.toFixed(2)}MB`));
},
},
{
name: "database",
check: async () => {
try {
await db.ping();
return Result.ok(undefined);
} catch (error) {
return Result.err(new Error("Database unreachable"));
}
},
},
{
name: "disk",
check: async () => {
const free = await getDiskSpace();
return free > 100 * 1024 * 1024 // 100MB
? Result.ok(undefined)
: Result.err(new Error("Low disk space"));
},
},
]);
```
### Exposing Health via IPC
```typescript
ipcServer.onMessage(async (msg) => {
if (msg.type === "health") {
const result = await healthChecker.check();
return {
healthy: result.isOk(),
checks: result.isOk() ? result.value : result.error,
};
}
});
```
### Periodic Health Checks
```typescript
const HEALTH_INTERVAL = 30000; // 30 seconds
setInterval(async () => {
const result = await healthChecker.check();
if (result.isErr()) {
logger.warn("Health check failed", { checks: result.error });
}
}, HEALTH_INTERVAL);
```
## PID File Management
### XDG Paths
```typescript
import { getLockPath, getSocketPath, getLogPath } from "@outfitter/daemon";
// PID file: ~/.local/state/my-daemon/my-daemon.pid
const pidPath = getLockPath("my-daemon");
// Socket: ~/.local/state/my-daemon/my-daemon.sock
const socketPath = getSocketPath("my-daemon");
// Logs: ~/.local/state/my-daemon/logs/
const logDir = getLogPath("my-daemon");
```
### Checking if Running
```typescript
import { isDaemonRunning, getDaemonPid } from "@outfitter/daemon";
if (await isDaemonRunning("my-daemon")) {
const pid = await getDaemonPid("my-daemon");
console.log(`Daemon already running (PID: ${pid})`);
process.exit(1);
}
```
## CLI Integration
### Start Command
```typescript
export const startCommand = command("start")
.option("-d, --detach", "Run in background")
.action(async ({ flags }) => {
if (await isDaemonRunning("my-daemon")) {
console.log("Daemon already running");
return;
}
if (flags.detach) {
// Spawn detached process
spawn("bun", ["run", "src/daemon.ts"], {
detached: true,
stdio: "ignore",
}).unref();
console.log("Daemon started in background");
} else {
// Run in foreground
await runDaemon();
}
})
.build();
```
### Stop Command
```typescript
export const stopCommand = command("stop")
.action(async () => {
const client = createIpcClient(getSocketPath("my-daemon"));
try {
await client.connect();
await client.send({ type: "shutdown" });
console.log("Daemon stopped");
} catch {
console.log("Daemon not running");
} finally {
client.close();
}
})
.build();
```
### Status Command
```typescript
export const statusCommand = command("status")
.action(async () => {
const client = createIpcClient(getSocketPath("my-daemon"));
try {
await client.connect();
const status = await client.send<Status>({ type: "status" });
console.log("Status:", status);
} catch {
console.log("Daemon not running");
} finally {
client.close();
}
})
.build();
```
## Best Practices
1. **Graceful shutdown** - Register cleanup handlers with `onShutdown`
2. **Health checks** - Monitor critical dependencies
3. **IPC protocol** - Use structured message types
4. **PID files** - Use XDG paths for consistency
5. **Logging** - Log lifecycle events for debugging
6. **CLI commands** - Provide start/stop/status commands
@@ -0,0 +1,242 @@
# Error Taxonomy
Ten error categories that map to exit codes (CLI) and HTTP status codes (API).
## Categories
| Category | Exit | HTTP | Class | When to Use |
|----------|------|------|-------|-------------|
| `validation` | 1 | 400 | `ValidationError` | Invalid input, schema failures, constraint violations |
| `not_found` | 2 | 404 | `NotFoundError` | Resource doesn't exist |
| `conflict` | 3 | 409 | `ConflictError` | Already exists, version mismatch, optimistic lock failure |
| `permission` | 4 | 403 | `PermissionError` | Forbidden action, insufficient privileges |
| `timeout` | 5 | 504 | `TimeoutError` | Operation took too long |
| `rate_limit` | 6 | 429 | `RateLimitError` | Too many requests, quota exceeded |
| `network` | 7 | 503 | `NetworkError` | Connection failures, DNS errors, unreachable hosts |
| `internal` | 8 | 500 | `InternalError` | Unexpected errors, bugs, unhandled cases |
| `auth` | 9 | 401 | `AuthError` | Authentication required, invalid credentials |
| `cancelled` | 130 | 499 | `CancelledError` | User interrupted (Ctrl+C), operation aborted |
## Error Classes
All errors extend `OutfitterError` and have:
```typescript
interface OutfitterError {
readonly _tag: string; // Discriminator for pattern matching
readonly category: ErrorCategory; // One of the 10 categories
readonly message: string; // Human-readable message
readonly details?: unknown; // Additional context
}
```
### ValidationError
```typescript
import { ValidationError } from "@outfitter/contracts";
// Basic
new ValidationError("Invalid email format");
// With details
new ValidationError("Validation failed", {
field: "email",
value: "not-an-email",
constraint: "email",
});
// From Zod
const result = schema.safeParse(input);
if (!result.success) {
return Result.err(new ValidationError("Invalid input", {
issues: result.error.issues,
}));
}
```
### NotFoundError
```typescript
import { NotFoundError } from "@outfitter/contracts";
// Resource type and ID
new NotFoundError("user", "user-123");
// Access properties
error.resourceType; // "user"
error.resourceId; // "user-123"
error.message; // "user not found: user-123"
```
### ConflictError
```typescript
import { ConflictError } from "@outfitter/contracts";
// Already exists
new ConflictError("User already exists", { email: "user@example.com" });
// Version mismatch
new ConflictError("Version mismatch", {
expected: 5,
actual: 7,
});
```
### PermissionError
```typescript
import { PermissionError } from "@outfitter/contracts";
new PermissionError("Cannot delete admin users", {
action: "delete",
resource: "user",
resourceId: "admin-1",
});
```
### TimeoutError
```typescript
import { TimeoutError } from "@outfitter/contracts";
new TimeoutError("Database query timed out", {
operation: "findUsers",
timeoutMs: 5000,
});
```
### RateLimitError
```typescript
import { RateLimitError } from "@outfitter/contracts";
new RateLimitError("API rate limit exceeded", {
limit: 100,
window: "1m",
retryAfter: 30,
});
```
### NetworkError
```typescript
import { NetworkError } from "@outfitter/contracts";
new NetworkError("Failed to connect to API", {
host: "api.example.com",
code: "ECONNREFUSED",
});
```
### InternalError
```typescript
import { InternalError } from "@outfitter/contracts";
// Wrap unexpected errors
try {
await riskyOperation();
} catch (error) {
return Result.err(new InternalError("Unexpected error", { cause: error }));
}
```
### AuthError
```typescript
import { AuthError } from "@outfitter/contracts";
new AuthError("Invalid API key");
new AuthError("Token expired", { expiredAt: "2024-01-01T00:00:00Z" });
```
### CancelledError
```typescript
import { CancelledError } from "@outfitter/contracts";
if (ctx.signal.aborted) {
return Result.err(new CancelledError("Operation cancelled by user"));
}
```
## Pattern Matching
Use `_tag` for type-safe error handling:
```typescript
if (result.isErr()) {
switch (result.error._tag) {
case "ValidationError":
console.log("Invalid input:", result.error.details);
break;
case "NotFoundError":
console.log(`${result.error.resourceType} not found`);
break;
case "ConflictError":
console.log("Conflict:", result.error.message);
break;
default:
console.log("Error:", result.error.message);
}
}
```
## Exit Code Mapping
```typescript
import { getExitCode } from "@outfitter/contracts";
const exitCode = getExitCode(error.category);
process.exit(exitCode);
```
## HTTP Status Mapping
```typescript
import { getStatusCode } from "@outfitter/contracts";
const status = getStatusCode(error.category);
res.status(status).json({ error: error.message });
```
## ERROR_CODES Constant
Use `ERROR_CODES` for type-safe category validation and iteration:
```typescript
import { ERROR_CODES, type ErrorCategory } from "@outfitter/contracts";
// ERROR_CODES is a readonly object mapping category names to exit codes
ERROR_CODES.validation; // 1
ERROR_CODES.not_found; // 2
ERROR_CODES.conflict; // 3
// ... etc
// Validate a category exists
const isValidCategory = (cat: string): cat is ErrorCategory => {
return cat in ERROR_CODES;
};
// Iterate over all categories
for (const [category, exitCode] of Object.entries(ERROR_CODES)) {
console.log(`${category}: exit ${exitCode}`);
}
```
## Creating Custom Errors
Extend the base classes for domain-specific errors:
```typescript
import { ValidationError } from "@outfitter/contracts";
export class EmailValidationError extends ValidationError {
constructor(email: string) {
super("Invalid email format", { email, field: "email" });
}
}
```
The category is inherited, so exit codes and HTTP status work automatically.
@@ -0,0 +1,147 @@
# File Operations Patterns
Deep dive into @outfitter/file-ops patterns for safe file handling.
## Secure Paths
Prevent path traversal attacks with `securePath`:
```typescript
import { securePath } from "@outfitter/file-ops";
const path = securePath("/data", userInput);
// Throws if userInput tries to escape /data via ../
```
## Atomic Writes
Write files atomically to prevent corruption:
```typescript
import { writeFileAtomic } from "@outfitter/file-ops";
await writeFileAtomic("/path/to/file.json", JSON.stringify(data, null, 2));
// Writes to temp file, then renames (atomic on POSIX)
```
## File Locking
### Exclusive Lock
For write operations that need exclusive access:
```typescript
import { withExclusiveLock } from "@outfitter/file-ops";
const result = await withExclusiveLock("/path/to/file.lock", async () => {
const data = await Bun.file("/path/to/data.json").json();
data.counter += 1;
await writeFileAtomic("/path/to/data.json", JSON.stringify(data));
return data;
});
if (result.isErr()) {
// Lock acquisition failed or operation threw
}
```
### Shared Lock (Reader-Writer)
Use `withSharedLock()` for read operations that can run concurrently:
```typescript
import { withSharedLock, withExclusiveLock } from "@outfitter/file-ops";
// Multiple readers can hold shared locks simultaneously
const readResult = await withSharedLock("/path/to/data.lock", async () => {
return await Bun.file("/path/to/data.json").json();
});
// Writers need exclusive lock (blocks readers)
const writeResult = await withExclusiveLock("/path/to/data.lock", async () => {
const data = await Bun.file("/path/to/data.json").json();
data.updated = Date.now();
await writeFileAtomic("/path/to/data.json", JSON.stringify(data));
return data;
});
```
**Lock fairness note:** Reader-writer locks can cause starvation. With many concurrent readers, writers may wait indefinitely (and vice versa). For high-contention scenarios, consider using exclusive locks only or implementing application-level queuing.
### Lock Options
```typescript
await withExclusiveLock("/path/to/file.lock", operation, {
timeout: 5000, // Max wait time in ms (default: 10000)
retryDelay: 100, // Delay between retries (default: 50)
staleThreshold: 60000, // Consider lock stale after this many ms
});
```
### Lock File Conventions
- Use `.lock` extension for lock files
- Place lock files alongside the protected resource
- Use consistent lock file paths across all accessors
```typescript
// Good: Lock file next to data file
const dataPath = "/data/users.json";
const lockPath = "/data/users.json.lock";
// Good: Named lock in XDG state
import { getStatePath } from "@outfitter/config";
const lockPath = getStatePath("myapp", "db.lock");
```
## Safe Directory Operations
### Ensure Directory Exists
```typescript
import { ensureDir } from "@outfitter/file-ops";
await ensureDir("/path/to/nested/dir");
// Creates all parent directories if needed
```
### Safe Removal
```typescript
import { safeRemove } from "@outfitter/file-ops";
await safeRemove("/path/to/file-or-dir");
// No error if doesn't exist, removes recursively if dir
```
## Temp Files
### Create Temp File
```typescript
import { createTempFile } from "@outfitter/file-ops";
const tempPath = await createTempFile("myapp", ".json");
// Returns path like /tmp/myapp-abc123.json
```
### With Cleanup
```typescript
import { withTempFile } from "@outfitter/file-ops";
const result = await withTempFile("myapp", ".json", async (tempPath) => {
await Bun.write(tempPath, JSON.stringify(data));
return await processFile(tempPath);
});
// Temp file automatically cleaned up
```
## Best Practices
1. **Always use atomic writes** for critical data
2. **Lock before read-modify-write** operations
3. **Use shared locks** for read-only operations to improve concurrency
4. **Validate paths** with `securePath` before using user input
5. **Clean up temp files** with `withTempFile` pattern
6. **Use XDG paths** from `@outfitter/config` for state/cache files
@@ -0,0 +1,211 @@
# Handler Contract
The core abstraction in Outfitter Stack. Handlers are pure functions that accept typed input and context, returning `Result<TOutput, TError>`.
## Signature
```typescript
type Handler<TInput, TOutput, TError extends OutfitterError> = (
input: TInput,
ctx: HandlerContext
) => Promise<Result<TOutput, TError>>;
```
## Type Parameters
| Parameter | Description |
|-----------|-------------|
| `TInput` | Input type (use `unknown` for raw input that needs validation) |
| `TOutput` | Success return type |
| `TError` | Union of possible error types (must extend `OutfitterError`) |
## Handler Structure
```typescript
import {
Result,
ValidationError,
NotFoundError,
createValidator,
type Handler,
} from "@outfitter/contracts";
import { z } from "zod";
// 1. Define input schema
const InputSchema = z.object({
id: z.string().min(1),
options: z.object({
includeDeleted: z.boolean().default(false),
}).optional(),
});
// 2. Create validator
const validateInput = createValidator(InputSchema);
// 3. Define output type
interface UserOutput {
id: string;
name: string;
email: string;
}
// 4. Implement handler
export const getUser: Handler<unknown, UserOutput, ValidationError | NotFoundError> = async (
rawInput,
ctx
) => {
// Validate input
const inputResult = validateInput(rawInput);
if (inputResult.isErr()) return inputResult;
const input = inputResult.value;
// Log with context
ctx.logger.debug("Fetching user", { userId: input.id });
// Business logic
const user = await db.users.findById(input.id);
if (!user) {
return Result.err(new NotFoundError("user", input.id));
}
// Return success
return Result.ok(user);
};
```
## Why Handlers?
### Transport Agnostic
Handlers know nothing about:
- CLI flags and arguments
- HTTP headers and status codes
- MCP tool schemas
- WebSocket messages
This separation means one handler serves all transports.
### Testability
Test handlers directly without transport layer:
```typescript
import { createContext } from "@outfitter/contracts";
test("getUser returns user", async () => {
const ctx = createContext({});
const result = await getUser({ id: "user-1" }, ctx);
expect(result.isOk()).toBe(true);
expect(result.value.name).toBe("Alice");
});
```
### Composability
Handlers can call other handlers:
```typescript
const createOrder: Handler<CreateOrderInput, Order, OrderError> = async (input, ctx) => {
// Call another handler
const userResult = await getUser({ id: input.userId }, ctx);
if (userResult.isErr()) {
return Result.err(new ValidationError("Invalid user", { userId: input.userId }));
}
// Continue with order creation
const order = await db.orders.create({
user: userResult.value,
items: input.items,
});
return Result.ok(order);
};
```
### Type Safety
TypeScript knows all possible outcomes:
```typescript
const result = await getUser({ id: "123" }, ctx);
if (result.isOk()) {
// result.value is UserOutput
console.log(result.value.name);
} else {
// result.error is ValidationError | NotFoundError
switch (result.error._tag) {
case "ValidationError":
console.log(result.error.details);
break;
case "NotFoundError":
console.log(result.error.resourceId);
break;
}
}
```
## Validation Pattern
Always validate at handler entry:
```typescript
const handler: Handler<unknown, Output, ValidationError | OtherError> = async (rawInput, ctx) => {
// First: validate
const inputResult = validateInput(rawInput);
if (inputResult.isErr()) return inputResult;
const input = inputResult.value; // Now typed!
// Rest of handler uses validated input
};
```
## Context Usage
Access cross-cutting concerns via context:
```typescript
const handler: Handler<Input, Output, Error> = async (input, ctx) => {
// Logging
ctx.logger.info("Processing", { input });
// Request tracing
const requestId = ctx.requestId;
// Configuration
const apiUrl = ctx.config.apiUrl;
// Cancellation
if (ctx.signal.aborted) {
return Result.err(new CancelledError("Operation cancelled"));
}
// Workspace paths
const filePath = path.join(ctx.workspaceRoot, input.filename);
};
```
## Error Handling
Never throw in handlers. Return `Result.err()`:
```typescript
// BAD
if (!user) throw new Error("Not found");
// GOOD
if (!user) return Result.err(new NotFoundError("user", id));
```
Use taxonomy error classes for consistent categorization:
```typescript
import {
ValidationError,
NotFoundError,
ConflictError,
PermissionError,
InternalError,
} from "@outfitter/contracts";
```
@@ -0,0 +1,263 @@
# Logging Patterns
Deep dive into @outfitter/logging patterns.
## Creating a Logger
```typescript
import { createLogger, createConsoleSink } from "@outfitter/logging";
const logger = createLogger({
name: "my-app",
level: "info",
sinks: [createConsoleSink()],
redaction: { enabled: true },
});
```
## Log Levels
| Level | Method | Use For |
|-------|--------|---------|
| `trace` | `logger.trace()` | Very detailed debugging |
| `debug` | `logger.debug()` | Development debugging |
| `info` | `logger.info()` | Normal operations |
| `warn` | `logger.warn()` | Unexpected but handled |
| `error` | `logger.error()` | Failures requiring attention |
| `fatal` | `logger.fatal()` | Unrecoverable failures |
Level hierarchy: `trace` < `debug` < `info` < `warn` < `error` < `fatal`
Setting level to `info` hides `trace` and `debug`.
## Structured Logging
Always use metadata objects:
```typescript
// GOOD: Structured metadata
logger.info("User created", {
userId: user.id,
email: user.email,
duration: performance.now() - start,
});
// BAD: String concatenation
logger.info("User " + user.name + " created in " + duration + "ms");
```
## Child Loggers
Add context that persists across calls:
```typescript
import { createChildLogger } from "@outfitter/logging";
const requestLogger = createChildLogger(logger, {
requestId: ctx.requestId,
handler: "createUser",
});
// All logs include requestId and handler
requestLogger.info("Processing"); // Has requestId, handler
requestLogger.debug("Validated input"); // Has requestId, handler
requestLogger.info("User created", { userId }); // Has requestId, handler, userId
```
## Redaction
### Enable Redaction
```typescript
const logger = createLogger({
name: "my-app",
level: "info",
sinks: [createConsoleSink()],
redaction: { enabled: true },
});
logger.info("Config", {
apiKey: "secret-123", // Logged as "[REDACTED]"
password: "hunter2", // Logged as "[REDACTED]"
email: "user@example.com" // Not redacted
});
```
### Default Redaction Patterns
Automatically redacted:
- `password`, `pwd`
- `apiKey`, `api_key`
- `secret`, `secretKey`
- `token`, `accessToken`
- `auth`, `authorization`
- `key` (when containing sensitive data)
- `credential`, `credentials`
### Custom Patterns
```typescript
const logger = createLogger({
name: "my-app",
redaction: {
enabled: true,
patterns: [
"password",
"apiKey",
"myCustomSecret",
"internalToken",
],
},
});
```
### Deep Redaction
Nested values are also redacted:
```typescript
logger.info("Request", {
headers: {
authorization: "Bearer token", // Redacted
},
body: {
user: {
password: "secret", // Redacted
},
},
});
```
## Sinks
### Console Sink
```typescript
import { createConsoleSink } from "@outfitter/logging";
const consoleSink = createConsoleSink({
colorize: true, // ANSI colors
prettyPrint: true, // Formatted output
timestampFormat: "iso", // ISO 8601 timestamps
});
```
### File Sink
```typescript
import { createFileSink } from "@outfitter/logging";
const fileSink = createFileSink({
path: "/var/log/myapp/app.log",
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5, // Keep 5 rotated files
});
```
### Multiple Sinks
```typescript
const logger = createLogger({
name: "my-app",
level: "debug",
sinks: [
createConsoleSink({ level: "info" }), // Console: info+
createFileSink({ // File: debug+
path: "/var/log/myapp/debug.log",
level: "debug",
}),
],
});
```
### Custom Sink
```typescript
const customSink = {
log: (record) => {
// Send to external service
externalService.send({
level: record.level,
message: record.message,
metadata: record.metadata,
timestamp: record.timestamp,
});
},
};
const logger = createLogger({
name: "my-app",
sinks: [customSink],
});
```
## Environment Configuration
```typescript
const logger = createLogger({
name: "my-app",
level: process.env.LOG_LEVEL || "info",
sinks: [
createConsoleSink({
colorize: process.stdout.isTTY,
prettyPrint: process.env.NODE_ENV !== "production",
}),
],
});
```
## Handler Context Integration
```typescript
import { createContext } from "@outfitter/contracts";
import { createLogger, createChildLogger } from "@outfitter/logging";
const baseLogger = createLogger({ name: "my-app", level: "info" });
export function createHandlerContext() {
const ctx = createContext({ logger: baseLogger });
// Child logger with requestId
return {
...ctx,
logger: createChildLogger(baseLogger, { requestId: ctx.requestId }),
};
}
// In handler
const myHandler: Handler<Input, Output, Error> = async (input, ctx) => {
ctx.logger.info("Processing", { input }); // Includes requestId
// ...
};
```
## Performance
### Conditional Logging
```typescript
// Level check before expensive operations
if (logger.isEnabled("debug")) {
const expensiveData = computeDebugInfo();
logger.debug("Debug info", { data: expensiveData });
}
```
### Lazy Evaluation
```typescript
logger.debug("State", () => ({
// Only computed if debug level is enabled
memory: process.memoryUsage(),
connections: getActiveConnections(),
}));
```
## Best Practices
1. **Structured metadata** - Always use objects, not string concatenation
2. **Child loggers** - Add request context that persists
3. **Enable redaction** - Prevent secrets from leaking
4. **Level per environment** - Debug in dev, info in prod
5. **Request IDs** - Include for tracing across handlers
6. **Lazy evaluation** - Avoid expensive computations at disabled levels
@@ -0,0 +1,279 @@
# MCP Server Patterns
Deep dive into @outfitter/mcp patterns.
## Creating a Server
```typescript
import { createMcpServer, defineTool } from "@outfitter/mcp";
const server = createMcpServer({
name: "my-server",
version: "0.1.0",
description: "Server for AI agents",
});
// Register tools before start
server.registerTool(searchTool);
server.registerTool(createTool);
// Start server
server.start();
```
## Tool Definition
### Using defineTool()
The `defineTool()` helper provides full type inference from the Zod schema:
```typescript
import { defineTool } from "@outfitter/mcp";
import { Result, ValidationError } from "@outfitter/contracts";
import { z } from "zod";
const InputSchema = z.object({
query: z.string().min(1).describe("Search query"),
limit: z.number().int().positive().default(10).describe("Max results"),
});
export const searchTool = defineTool({
name: "search",
description: "Search for items. Use when user asks to find or search.",
inputSchema: InputSchema,
handler: async (input): Promise<Result<SearchOutput, ValidationError>> => {
// input is automatically typed from InputSchema
const results = await performSearch(input.query, input.limit);
return Result.ok({ results, total: results.length });
},
});
```
### Schema Best Practices
```typescript
const InputSchema = z.object({
// Always use .describe() for AI understanding
query: z.string().describe("The search term to look for"),
// Provide defaults where sensible
limit: z.number().default(10).describe("Maximum number of results"),
// Use enums for fixed choices
sortBy: z.enum(["name", "date", "relevance"]).default("relevance")
.describe("Field to sort results by"),
// Mark optional fields explicitly
tags: z.array(z.string()).optional().describe("Filter by tags"),
});
```
### Tool with Context
```typescript
export const myTool = defineTool({
name: "my_tool",
description: "Tool description",
inputSchema: InputSchema,
handler: async (input, ctx) => {
ctx.logger.debug("Tool invoked", { input });
const result = await myHandler(input, ctx);
if (result.isErr()) {
ctx.logger.error("Tool failed", { error: result.error });
}
return result;
},
});
```
## Resources
### Static Resource
```typescript
server.registerResource({
uri: "config://settings",
name: "Configuration",
description: "Current server configuration",
mimeType: "application/json",
read: async () => {
return JSON.stringify(config, null, 2);
},
});
```
### Dynamic Resource
```typescript
server.registerResource({
uri: "data://users/{id}",
name: "User Data",
description: "User information by ID",
mimeType: "application/json",
read: async (uri) => {
const id = uri.split("/").pop();
const user = await getUser(id);
return JSON.stringify(user);
},
});
```
### Resource List
```typescript
server.registerResourceList({
uri: "data://users",
name: "Users",
description: "List of all users",
list: async () => {
const users = await getAllUsers();
return users.map(u => ({
uri: `data://users/${u.id}`,
name: u.name,
description: u.email,
}));
},
});
```
## Prompts
```typescript
server.registerPrompt({
name: "analyze",
description: "Analyze data with specific focus",
arguments: [
{ name: "focus", description: "What to focus on", required: true },
{ name: "depth", description: "Analysis depth", required: false },
],
get: async (args) => {
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Analyze with focus on: ${args.focus}. Depth: ${args.depth || "normal"}`,
},
},
],
};
},
});
```
## Error Handling
### Returning Errors
```typescript
handler: async (input) => {
if (!input.query) {
return Result.err(new ValidationError("Query is required"));
}
const item = await findItem(input.id);
if (!item) {
return Result.err(new NotFoundError("item", input.id));
}
return Result.ok(item);
}
```
### Error Categories in MCP
| Category | MCP Behavior |
|----------|--------------|
| validation | Tool returns error with details |
| not_found | Tool returns error with resource info |
| internal | Tool returns generic error, logs full error |
## Server Configuration
### Claude Desktop
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"my-server": {
"command": "bun",
"args": ["run", "/path/to/server.ts"]
}
}
}
```
### With Environment Variables
```json
{
"mcpServers": {
"my-server": {
"command": "bun",
"args": ["run", "/path/to/server.ts"],
"env": {
"API_KEY": "secret",
"LOG_LEVEL": "debug"
}
}
}
}
```
## Deferred Tool Loading
For tools that are expensive to load:
```typescript
import { defineDeferredTool } from "@outfitter/mcp";
const heavyTool = defineDeferredTool({
name: "heavy_tool",
description: "Expensive tool loaded on demand",
load: async () => {
const { heavyTool } = await import("./heavy-tool.js");
return heavyTool;
},
});
// Deferred tools use the same registerTool() API - the server
// detects the deferred wrapper and handles lazy loading internally
server.registerTool(heavyTool);
```
## Testing MCP Servers
```typescript
import { createMcpHarness } from "@outfitter/testing";
const harness = createMcpHarness(myTool);
test("tool returns results", async () => {
const result = await harness.invoke({ query: "test" });
expect(result.isOk()).toBe(true);
expect(result.value.results).toHaveLength(3);
});
```
## Best Practices
1. **Descriptive schemas** - Use `.describe()` on every field
2. **Sensible defaults** - Provide `.default()` where appropriate
3. **Error categories** - Use taxonomy errors for proper handling
4. **Logging** - Log tool invocations for debugging
5. **Deferred loading** - Lazy load expensive tools
6. **Test harnesses** - Use `createMcpHarness` for testing
@@ -0,0 +1,238 @@
# Result Utilities
Operations for working with `Result<T, E>` from `better-result`.
## Creating Results
```typescript
import { Result } from "@outfitter/contracts";
// Success
const ok = Result.ok({ name: "Alice", id: "1" });
// Failure
const err = Result.err(new NotFoundError("user", "123"));
```
## Checking Results
```typescript
// Boolean check
if (result.isOk()) {
console.log(result.value); // TypeScript knows type
}
if (result.isErr()) {
console.log(result.error); // TypeScript knows error type
}
```
## Accessing Values
```typescript
// Safe access (only after isOk check)
if (result.isOk()) {
const user = result.value;
}
// Unsafe access (throws if error)
const user = result.unwrap(); // Throws if err!
// With default
const user = result.unwrapOr(defaultUser);
// With default factory
const user = result.unwrapOrElse(() => createDefaultUser());
```
## Pattern Matching
```typescript
const message = result.match({
ok: (user) => `Found ${user.name}`,
err: (error) => `Error: ${error.message}`,
});
```
## Transforming Results
### Map (transform success value)
```typescript
const nameResult = result.map((user) => user.name);
// Result<string, Error>
```
### MapErr (transform error)
```typescript
const mappedResult = result.mapErr((error) => new WrappedError(error));
// Result<User, WrappedError>
```
### FlatMap / AndThen (chain operations)
```typescript
const orderResult = getUserResult.flatMap((user) => getOrders(user.id));
// Result<Orders, UserError | OrderError>
```
## Combining Results
### combine2, combine3, etc.
Combine multiple results into a tuple:
```typescript
import { combine2, combine3 } from "@outfitter/contracts";
const result = combine2(userResult, orderResult);
// Result<[User, Order], UserError | OrderError>
if (result.isOk()) {
const [user, order] = result.value;
}
```
### combineAll
Combine an array of results:
```typescript
import { combineAll } from "@outfitter/contracts";
const results = await Promise.all(ids.map((id) => getUser(id)));
const combined = combineAll(results);
// Result<User[], Error>
```
### combineObject
Combine an object of results:
```typescript
import { combineObject } from "@outfitter/contracts";
const combined = combineObject({
user: userResult,
orders: ordersResult,
settings: settingsResult,
});
// Result<{ user: User; orders: Order[]; settings: Settings }, Error>
```
## Error Recovery
### OrElse (try alternative on error)
```typescript
const result = primaryResult.orElse(() => fallbackResult);
```
### Recover (convert error to success)
```typescript
const result = userResult.recover((error) => {
if (error._tag === "NotFoundError") {
return Result.ok(defaultUser);
}
return Result.err(error);
});
```
## Async Patterns
### Sequential execution
```typescript
const result = await getUser(id)
.then((r) => r.isOk() ? getOrders(r.value.id) : Promise.resolve(r));
```
### With async/await
```typescript
async function getUserWithOrders(id: string): Promise<Result<UserWithOrders, Error>> {
const userResult = await getUser(id);
if (userResult.isErr()) return userResult;
const ordersResult = await getOrders(userResult.value.id);
if (ordersResult.isErr()) return ordersResult;
return Result.ok({
user: userResult.value,
orders: ordersResult.value,
});
}
```
## Validation Helper
The `createValidator` utility returns `Result`:
```typescript
import { createValidator } from "@outfitter/contracts";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
age: z.number().int().positive(),
});
const validate = createValidator(schema);
const result = validate({ email: "test@example.com", age: 25 });
// Result<{ email: string; age: number }, ValidationError>
```
## Common Patterns
### Early return on error
```typescript
const handler: Handler<Input, Output, Error> = async (input, ctx) => {
const validateResult = validate(input);
if (validateResult.isErr()) return validateResult;
const userResult = await getUser(validateResult.value.userId);
if (userResult.isErr()) return userResult;
const orderResult = await createOrder(userResult.value);
if (orderResult.isErr()) return orderResult;
return Result.ok(orderResult.value);
};
```
### Collect all errors
```typescript
const errors: ValidationError[] = [];
if (!input.name) errors.push(new ValidationError("Name required"));
if (!input.email) errors.push(new ValidationError("Email required"));
if (errors.length > 0) {
return Result.err(new ValidationError("Multiple errors", { errors }));
}
```
### Wrap throwing functions
```typescript
function wrapThrowable<T>(fn: () => T): Result<T, InternalError> {
try {
return Result.ok(fn());
} catch (error) {
return Result.err(new InternalError("Unexpected error", { cause: error }));
}
}
async function wrapAsync<T>(fn: () => Promise<T>): Promise<Result<T, InternalError>> {
try {
return Result.ok(await fn());
} catch (error) {
return Result.err(new InternalError("Unexpected error", { cause: error }));
}
}
```
@@ -0,0 +1,254 @@
# Stack Testing
Test patterns for @outfitter/* packages.
## Test Structure
```
src/
├── handlers/
│ └── get-user.ts
└── __tests__/
├── get-user.test.ts
└── __snapshots__/
└── get-user.test.ts.snap
```
## Handler Testing
Test handlers directly without transport layer:
```typescript
import { describe, test, expect } from "bun:test";
import { createContext } from "@outfitter/contracts";
import { getUser } from "../handlers/get-user.js";
describe("getUser", () => {
test("returns user when found", async () => {
const ctx = createContext({});
const result = await getUser({ id: "user-1" }, ctx);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
id: "user-1",
name: "Alice",
});
});
test("returns NotFoundError when user missing", async () => {
const ctx = createContext({});
const result = await getUser({ id: "missing" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.resourceId).toBe("missing");
});
test("returns ValidationError for invalid input", async () => {
const ctx = createContext({});
const result = await getUser({ id: "" }, ctx);
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
});
```
## Test Fixtures
Use `createFixture` for deep-merged test data:
```typescript
import { createFixture } from "@outfitter/testing";
interface User {
id: string;
name: string;
email: string;
settings: { theme: string; notifications: boolean };
}
const createUser = createFixture<User>({
id: "user-1",
name: "Test User",
email: "test@example.com",
settings: { theme: "light", notifications: true },
});
test("user with custom settings", async () => {
const user = createUser({ settings: { theme: "dark" } });
// user.settings.notifications is still true (deep merge)
});
```
## Temporary Directories
Use `withTempDir` for isolated file operations:
```typescript
import { withTempDir } from "@outfitter/testing";
test("writes config file", async () => {
await withTempDir(async (dir) => {
const result = await writeConfig({ dir, data: { key: "value" } }, ctx);
expect(result.isOk()).toBe(true);
const content = await Bun.file(`${dir}/config.json`).json();
expect(content).toEqual({ key: "value" });
});
});
```
## Environment Mocking
Use `withEnv` for environment variable testing:
```typescript
import { withEnv } from "@outfitter/testing";
test("uses custom log level", async () => {
await withEnv({ LOG_LEVEL: "debug" }, async () => {
const config = loadConfig();
expect(config.logLevel).toBe("debug");
});
});
```
## CLI Testing
Use `createCliHarness` for CLI command testing:
```typescript
import { createCliHarness } from "@outfitter/testing";
import { listCommand } from "../commands/list.js";
const harness = createCliHarness(listCommand);
test("lists items in JSON mode", async () => {
const result = await harness.run(["--json"]);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('"items"');
});
test("exits with error for invalid flag", async () => {
const result = await harness.run(["--invalid"]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Unknown option");
});
```
## MCP Testing
Use `createMcpHarness` for MCP tool testing:
```typescript
import { createMcpHarness } from "@outfitter/testing";
import { searchTool } from "../tools/search.js";
const harness = createMcpHarness(searchTool);
test("returns search results", async () => {
const result = await harness.invoke({
query: "test",
limit: 10,
});
expect(result.isOk()).toBe(true);
expect(result.value.results).toHaveLength(3);
});
test("validates input schema", async () => {
const result = await harness.invoke({
query: "", // Invalid: min length 1
});
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("ValidationError");
});
```
## Context Mocking
Create mock context with logger spy:
```typescript
import { createContext } from "@outfitter/contracts";
import { createMockLogger } from "@outfitter/testing";
test("logs debug messages", async () => {
const mockLogger = createMockLogger();
const ctx = createContext({ logger: mockLogger });
await myHandler({ id: "1" }, ctx);
expect(mockLogger.calls.debug).toContainEqual([
"Processing",
{ id: "1" },
]);
});
```
## Snapshot Testing
Use Bun's snapshot testing:
```typescript
import { expect, test } from "bun:test";
test("output matches snapshot", async () => {
const result = await formatOutput(data);
expect(result).toMatchSnapshot();
});
```
Snapshots stored in `__snapshots__/*.snap`.
## Result Assertions
Custom matchers for Result types:
```typescript
// Check success
expect(result.isOk()).toBe(true);
expect(result.value).toEqual(expected);
// Check failure
expect(result.isErr()).toBe(true);
expect(result.error._tag).toBe("NotFoundError");
expect(result.error.category).toBe("not_found");
// Error details
expect(result.error.details).toMatchObject({
field: "email",
});
```
## Running Tests
```bash
# All tests
bun test
# Single file
bun test src/__tests__/get-user.test.ts
# Watch mode
bun test --watch
# With coverage
bun test --coverage
# Update snapshots
bun test --update-snapshots
```
## Best Practices
1. **Test handlers directly** - Skip transport layer for unit tests
2. **Use fixtures** - Create reusable test data with `createFixture`
3. **Isolate side effects** - Use `withTempDir` and `withEnv`
4. **Mock context** - Inject mock logger to verify logging
5. **Test error paths** - Verify correct error types and categories
6. **Snapshot outputs** - Use snapshots for complex output verification