Spry LogoSpry Docs

Universal Module

Cross-cutting utilities and core functionality in Spry.

Introduction

The universal module serves as the foundation of Spry, providing cross-cutting utilities and core functionality used throughout the entire system. Think of it as the "standard library" for Spry.


Purpose

The universal module provides essential infrastructure for:

  • DAG-based task execution - Build and execute dependency graphs for tasks that must run in a specific order
  • Shell command execution - Run system commands with rich event tracking and output handling
  • File system utilities - Work with files, paths, and directory trees
  • Terminal UI components - Build rich, interactive command-line interfaces
  • Configuration parsing - Handle various config formats (.gitignore, properties files, JSON schemas)
  • Shared helpers - Common utilities for strings, objects, interpolation, and more

Directory Structure

lib/universal/
├── task.ts                  # DAG execution engine - core task orchestration
├── event-bus.ts             # Typed event bus - pub/sub messaging
├── shell.ts                 # Shell command execution with events
├── depends.ts               # Dependency resolution utilities
├── code.ts                  # Code block utilities
├── code-comments.ts         # Comment extraction and handling
├── cline.ts                 # Command-line parsing for code fences
├── content-acquisition.ts   # Content loading from various sources
├── text-utils.ts            # String manipulation utilities
├── tmpl-literal-aide.ts     # Template literal helpers
├── json-stringify-aide.ts   # JSON serialization utilities
├── interpolate.ts           # String interpolation utilities
├── resource.ts              # Resource abstraction layer
├── path-tree.ts             # File tree representation
├── path-tree-tabular.ts     # Tabular display of path trees
├── gitignore.ts             # .gitignore file management
├── properties.ts            # Properties file parsing
├── zod-aide.ts              # Zod schema utilities
├── lister-tabular-tui.ts    # Tabular terminal UI builder
├── lister-tree-tui.ts       # Tree-based terminal UI builder
├── task-visuals.ts          # DAG visualization (ASCII, Mermaid)
├── os-user.ts               # Operating system user information
├── doctor.ts                # System diagnostics and health checks
├── watcher.ts               # File system watching with debouncing
├── collectable.ts           # Async collection utilities
├── pmd-shebang.ts           # Programmable Markdown shebang handling
├── posix-pi.ts              # POSIX process inspection
├── merge.ts                 # Deep object merging
├── reverse-proxy-simulate.ts # Reverse proxy simulation
├── version.ts               # Version management from git tags
└── sql-text.ts              # SQL text utilities

Core Modules

task.ts - DAG Execution Engine

The heart of Spry's task system, implementing a Directed Acyclic Graph (DAG) execution engine that handles task dependencies and parallel execution.

Building Execution Plans

import { executionPlan, executionSubplan, executeDAG } from "./task.ts";

// Create full execution plan from all tasks
const plan = executionPlan(tasks);

// Create subplan for specific targets (useful for "spry build test")
const subplan = executionSubplan(plan, ["build", "test"]);

// Execute the plan - tasks run in dependency order with parallelization
const results = await executeDAG(plan, async (task) => {
  // Your task execution logic
  console.log(`Running: ${task.taskId()}`);
  // Return execution result
  return { success: true, output: "done" };
});

Key Concepts:

  • Topological Sorting - Tasks are ordered so dependencies always run first
  • Parallel Execution - Independent tasks run concurrently for speed
  • Cycle Detection - Circular dependencies are caught and reported
  • Partial Plans - Execute only what's needed for specific targets

Task Interface

Every task must implement this interface:

interface Task {
  taskId(): string;           // Unique identifier
  dependencies(): string[];    // Task IDs this depends on
}

Event Buses for Progress Tracking

Monitor task execution with different verbosity levels:

import { verboseInfoTaskEventBus, errorOnlyTaskEventBus } from "./task.ts";

// Rich output with progress indicators, timing, and status
const bus = verboseInfoTaskEventBus({ style: "rich" });

// Minimal output - only show errors
const errorBus = errorOnlyTaskEventBus({ style: "plain" });

// Use with executeDAG
const results = await executeDAG(plan, handler, { bus });

Use Cases:

  • Build systems where tasks compile, test, and deploy in order
  • Data pipelines where transformations depend on previous steps
  • CI/CD workflows with complex dependency chains

event-bus.ts - Typed Event System

A generic, type-safe pub/sub event bus for loose coupling between components.

import { eventBus } from "./event-bus.ts";

// Define your event types - ensures type safety
type MyEvents = {
  "start": { id: string; timestamp: number };
  "done": { id: string; result: number; duration: number };
  "error": { id: string; error: Error };
};

const bus = eventBus<MyEvents>();

// Subscribe to events - handlers are fully typed
bus.on("start", (e) => console.log(`Starting ${e.id} at ${e.timestamp}`));
bus.on("done", (e) => console.log(`Done ${e.id}: ${e.result} (${e.duration}ms)`));
bus.on("error", (e) => console.error(`Failed ${e.id}:`, e.error.message));

// Emit events - TypeScript ensures correct payload
bus.emit("start", { id: "task-1", timestamp: Date.now() });
bus.emit("done", { id: "task-1", result: 0, duration: 1250 });
// bus.emit("done", { id: "task-1" }); // TS Error: missing fields!

Benefits:

  • Decouples components (emitters don't know about subscribers)
  • Type-safe event payloads catch bugs at compile time
  • Easy to test (mock the bus)
  • Multiple subscribers per event

shell.ts - Shell Command Execution

Execute shell commands with comprehensive event tracking, output capture, and multiple execution modes.

import { shell, verboseInfoShellEventBus } from "./shell.ts";

const bus = verboseInfoShellEventBus({ style: "rich" });
const sh = shell({ bus, cwd: Deno.cwd() });

// Execute single command - captures stdout/stderr
const result = await sh.spawnText("echo hello");
console.log(result.stdout); // "hello\n"

// Auto-detect execution mode from shebang
const autoResult = await sh.auto(`#!/bin/bash
echo "Hello from bash"
ls -la
`);

// Execute multi-line script via deno task
// Each line runs separately with its own events
const evalResult = await sh.denoTaskEval(`
echo "Step 1: Setup"
mkdir -p build
echo "Step 2: Compile"
deno bundle src/main.ts build/bundle.js
`);

Shell Events for Monitoring

The shell emits events throughout execution, enabling rich logging and progress tracking:

EventDescriptionPayload
spawn:startCommand starting execution{ cmd: string, cwd: string }
spawn:doneCommand completed successfully{ cmd: string, code: number, stdout: string }
spawn:errorCommand failed or errored{ cmd: string, error: Error }
task:line:startEval line starting (multi-line mode){ line: string, index: number }
task:line:doneEval line completed{ line: string, success: boolean }
shebang:tempfileTemporary script file created{ path: string, shebang: string }
shebang:cleanupTemporary file removed{ path: string }
auto:modeExecution mode detected{ mode: "shebang" | "eval" }

Use Cases:

  • Running build commands with progress tracking
  • Executing test suites with detailed output
  • Automating deployments with error handling
  • Creating shell-based task workflows

Utility Modules

cline.ts - Code Fence Command-Line Parsing

Parses code fence info strings (like ` bash task-name --flag value ) into structured data.

import { parseCodeFenceInfo } from "./cline.ts";

const info = parseCodeFenceInfo("bash task-name --descr 'Build the app' --depends setup,test");
// Returns:
// {
//   language: "bash",
//   identity: "task-name",
//   descr: "Build the app",
//   depends: ["setup", "test"]
// }

This is crucial for Spry's Markdown-based task definitions where code fences define executable tasks.


tmpl-literal-aide.ts - Template Literal Helpers

Utilities for working with template literals and multi-line strings.

import { dedentIfFirstLineBlank, indent, safeJsonStringify } from "./tmpl-literal-aide.ts";

// Remove leading whitespace (common in template literals)
const text = dedentIfFirstLineBlank(`
    function hello() {
        console.log("Hello");
    }
`);
// Result: "function hello() {\n    console.log(\"Hello\");\n}"

// Add consistent indentation
const indented = indent("line1\nline2\nline3", "  ");
// Result: "  line1\n  line2\n  line3"

// Safe JSON stringify with error handling
const json = safeJsonStringify({ nested: { data: [1, 2, 3] } });

Why This Matters:

Template literals preserve indentation from your code, which looks messy in output. These helpers normalize whitespace for cleaner results.


gitignore.ts - .gitignore Management

Programmatically manage .gitignore files without duplicating entries.

import { gitignore } from "./gitignore.ts";

// Add entries only if they don't exist
const result = await gitignore("dev-src.auto", ".env", "*.tmp");
// Returns:
// {
//   added: ["dev-src.auto", ".env"],      // New entries
//   preserved: ["*.tmp"]                   // Already existed
// }

Use Cases:

  • Auto-generating .gitignore during project setup
  • Ensuring generated files are always ignored
  • Tool-specific ignore patterns (e.g., Spry adds its own entries)

zod-aide.ts - Zod Schema Utilities

Bridge between JSON Schema and Zod for runtime validation.

import { jsonToZod } from "./zod-aide.ts";

// Convert JSON Schema to Zod schema
const schema = jsonToZod(`{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age": { "type": "number" },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["name", "age"]
}`);

// Use for runtime validation
const result = schema.safeParse({ name: "Alice", age: 30, email: "alice@example.com" });
if (result.success) {
  console.log("Valid:", result.data);
} else {
  console.error("Errors:", result.error.errors);
}

This enables using JSON Schema definitions (which are portable and tool-agnostic) with Zod's excellent TypeScript integration.


UI Modules

lister-tabular-tui.ts - Tabular Terminal UI

Build rich, formatted tables for the terminal with custom columns and styling.

import { ListerBuilder } from "./lister-tabular-tui.ts";

type Task = { name: string; status: "done" | "running" | "pending"; duration: number };

const tasks: Task[] = [
  { name: "build", status: "done", duration: 1234 },
  { name: "test", status: "running", duration: 567 },
  { name: "deploy", status: "pending", duration: 0 }
];

await new ListerBuilder<Task>()
  .declareColumns("name", "status", "duration")
  .from(tasks)
  .field("name", "name", { header: "Task Name" })
  .field("status", "status", { 
    header: "Status",
    format: (s) => s === "done" ? "✓" : s === "running" ? "⟳" : "○"
  })
  .field("duration", "duration", { 
    header: "Duration (ms)",
    format: (d) => d > 0 ? d.toString() : "—"
  })
  .build()
  .ls(true); // true = output to stdout

// Output:
// Task Name  Status  Duration (ms)
// build      ✓       1234
// test       ⟳       567
// deploy     ○       —

Features:

  • Custom formatters for each column
  • Automatic alignment and spacing
  • Header customization
  • Sorting and filtering support

lister-tree-tui.ts - Tree Terminal UI

Display hierarchical data as expandable trees in the terminal.

import { TreeLister, ListerBuilder } from "./lister-tree-tui.ts";

type FileRow = { path: string; size: number; type: string };

const files: FileRow[] = [
  { path: "src/main.ts", size: 1234, type: "file" },
  { path: "src/utils/helper.ts", size: 567, type: "file" },
  { path: "tests/main_test.ts", size: 890, type: "file" }
];

const base = new ListerBuilder<FileRow>()
  .declareColumns("path", "size", "type")
  .field("path", "path", { header: "File" })
  .field("size", "size", { header: "Size" })
  .field("type", "type", { header: "Type" });

const tree = TreeLister
  .wrap(base)
  .from(files)
  .byPath({ pathKey: "path", separator: "/" })
  .treeOn("path");

await tree.ls(true);

// Output:
// src/
//   main.ts (1234 bytes)
//   utils/
//     helper.ts (567 bytes)
// tests/
//   main_test.ts (890 bytes)

Perfect for displaying file systems, nested configurations, or any hierarchical data.


task-visuals.ts - DAG Visualization

Visualize task dependency graphs in multiple formats.

import { executionPlanVisuals, ExecutionPlanVisualStyle } from "./task-visuals.ts";

const visuals = executionPlanVisuals(plan);

// ASCII art representation (good for terminals)
console.log(visuals.visualText(ExecutionPlanVisualStyle.ASCII));
// Output:
// setup
//   ├─→ build
//   │    └─→ test
//   └─→ lint
// Mermaid diagram (for documentation/GitHub)
console.log(visuals.visualText(ExecutionPlanVisualStyle.Mermaid));
// Output:
// graph TD
//   setup --> build
//   build --> test
//   setup --> lint

Use Cases:

  • Debugging complex task dependencies
  • Documenting build processes
  • Visualizing CI/CD pipelines

System Modules

doctor.ts - System Diagnostics

Check if required tools and dependencies are available.

import { doctor } from "./doctor.ts";

const diags = doctor([
  "deno --version",
  "sqlpage --version", 
  "git --version"
]);

const result = await diags.run();
diags.render.cli(result);

// Output:
// ✓ deno 2.1.0
// ✗ sqlpage: command not found
// ✓ git version 2.43.0

Essential for tools that depend on external commands - helps users troubleshoot missing dependencies.


watcher.ts - File System Watching

Watch files for changes and trigger rebuilds automatically.

import { watcher } from "./watcher.ts";

const run = watcher(
  ["Spryfile.md", "src/**/*.ts"],  // Files/patterns to watch
  async () => {
    console.log("Files changed, rebuilding...");
    await runBuild();
  },
  { 
    debounceMs: 100,  // Wait 100ms after last change
    ignore: ["build/", "*.tmp"]
  }
);

await run(true); // true = watch mode, false = run once

Debouncing: Multiple rapid changes trigger only one rebuild after activity settles.


version.ts - Git-Based Versioning

Automatically determine version from git tags.

import { computeSemVerSync } from "./version.ts";

// Looks for git tags like v1.2.3
const version = computeSemVerSync(import.meta.url);
console.log(version); // "1.2.3"

// Use in your app
console.log(`MyTool version ${version}`);

This follows semantic versioning based on your git repository's tags, ensuring version numbers stay in sync with releases.


pmd-shebang.ts - Programmable Markdown

Handle executable Markdown files with shebangs.

import { generateShebang, isExecutableMarkdown } from "./pmd-shebang.ts";

// Generate proper shebang for executable Markdown
const shebang = generateShebang("./spry.ts");
// Returns: "#!/usr/bin/env -S deno run -A ./spry.ts runbook -m"

// Check if a file is executable Markdown
const content = await Deno.readTextFile("README.md");
if (isExecutableMarkdown(content)) {
  console.log("This Markdown file can be executed!");
}

What This Enables:

  • Markdown files that run as scripts (./README.md)
  • Literate programming (documentation IS the code)
  • Self-documenting build scripts

File System Modules

resource.ts - Resource Abstraction

Unified interface for file operations.

import { Resource } from "./resource.ts";

const res = new Resource("path/to/file.txt");

// Read content
const content = await res.read();

// Write content
await res.write("new content");

// Check existence
if (await res.exists()) {
  console.log("File exists");
}

// Get metadata
const info = await res.stat();
console.log(`Size: ${info.size} bytes`);

Abstracts away file system details, making it easier to swap between local files, remote URLs, or virtual file systems.


path-tree.ts - File Tree Representation

Build and display file system trees.

import { PathTree } from "./path-tree.ts";

const tree = new PathTree();
tree.add("src/main.ts");
tree.add("src/utils/helper.ts");
tree.add("src/utils/logger.ts");
tree.add("tests/main_test.ts");

console.log(tree.render());
// Output:
// src/
//   main.ts
//   utils/
//     helper.ts
//     logger.ts
// tests/
//   main_test.ts

Useful for displaying project structure, generating file lists, or visualizing changes.


content-acquisition.ts - Content Loading

Load content from various sources (files, URLs, stdin).

import { acquireContent, SourceRelativeTo } from "./content-acquisition.ts";

// Load from local file system
const local = await acquireContent(
  "docs/README.md",
  SourceRelativeTo.LocalFs
);

// Load from URL
const remote = await acquireContent(
  "https://example.com/config.json",
  SourceRelativeTo.Url
);

// Load from stdin
const stdin = await acquireContent(
  "-",
  SourceRelativeTo.Stdin
);

Handles the complexity of different content sources with a unified API.


Integration Patterns

Task Execution + Shell + Events

Common pattern: tasks that execute shell commands with progress tracking.

import { executionPlan, executeDAG } from "./task.ts";
import { shell, verboseInfoShellEventBus } from "./shell.ts";

const shellBus = verboseInfoShellEventBus({ style: "rich" });
const sh = shell({ bus: shellBus, cwd: Deno.cwd() });

const plan = executionPlan(tasks);
const results = await executeDAG(plan, async (task) => {
  // Each task executes shell commands
  await sh.denoTaskEval(task.getScript());
  return { success: true };
});

File Watching + Task Execution

Watch files and re-run tasks on changes.

import { watcher } from "./watcher.ts";
import { executionPlan, executeDAG } from "./task.ts";

const run = watcher(
  ["src/**/*.ts"],
  async () => {
    const plan = executionPlan(tasks);
    await executeDAG(plan, handler);
  },
  { debounceMs: 200 }
);

await run(true);

Best Practices

1. Use Event Buses for Monitoring

Don't hardcode console.log in your logic - emit events instead:

// Tight coupling
function runTask() {
  console.log("Starting...");
  // ...
  console.log("Done!");
}
// Use event bus
function runTask(bus: EventBus) {
  bus.emit("start", { id: taskId });
  // ...
  bus.emit("done", { id: taskId });
}

2. Leverage Type Safety

Use TypeScript's type system with the event bus:

// ✅ Define event types upfront
type Events = {
  "build:start": { target: string };
  "build:done": { target: string; artifacts: string[] };
};

const bus = eventBus<Events>();
// Now TypeScript catches payload errors!

3. Cache Execution Plans

Don't rebuild execution plans unnecessarily:

// ✅ Build once, reuse
const plan = executionPlan(tasks);

// Execute multiple times
await executeDAG(plan, handler1);
await executeDAG(plan, handler2);

4. Handle Shell Errors Gracefully

Always check shell command results:

const result = await sh.spawnText("some-command");
if (result.code !== 0) {
  console.error(`Command failed: ${result.stderr}`);
  throw new Error(`Exit code ${result.code}`);
}

5. Use Debouncing for Watchers

Prevent excessive rebuilds with appropriate debounce times:

// ✅ 100-500ms is usually good
const run = watcher(files, rebuild, { debounceMs: 200 });

Performance Considerations

Key Performance Points:

  • Task Execution: DAG parallelization can significantly speed up builds with independent tasks
  • File Watching: Debouncing prevents rebuild storms during rapid file changes
  • Shell Commands: Use spawnText for simple commands, denoTaskEval for complex scripts
  • Event Buses: Minimal overhead, but avoid emitting events in hot loops

Testing

# Run all universal tests
deno test lib/universal/

# Run specific test file
deno test lib/universal/task_test.ts

# Watch mode for development
deno test --watch lib/universal/

# Run with coverage
deno test --coverage=coverage lib/universal/
deno coverage coverage

Common Patterns

Pattern: Build System

// Define tasks with dependencies
const tasks = [
  { id: "clean", deps: [] },
  { id: "build", deps: ["clean"] },
  { id: "test", deps: ["build"] },
  { id: "deploy", deps: ["test"] }
];

// Execute with progress
const plan = executionPlan(tasks);
await executeDAG(plan, async (task) => {
  await sh.denoTaskEval(task.script);
});

Pattern: Development Watch

// Watch and rebuild automatically
await watcher(
  ["src/**/*.ts", "Spryfile.md"],
  async () => {
    const plan = executionSubplan(fullPlan, ["build"]);
    await executeDAG(plan, handler);
  },
  { debounceMs: 150 }
);

Pattern: CLI Tool with Doctor

// Check prerequisites before running
const diags = doctor(["deno --version", "git --version"]);
const health = await diags.run();

if (health.some(d => !d.success)) {
  console.error("Missing required tools!");
  diags.render.cli(health);
  Deno.exit(1);
}

// Proceed with main logic...

Summary

The universal module is the backbone of Spry, providing battle-tested primitives for building sophisticated command-line tools and build systems. Use these modules as building blocks for your own tools and workflows.

Key Capabilities:

  • DAG-based task execution with parallelization
  • Type-safe event bus for component communication
  • Rich shell command execution with progress tracking
  • Terminal UI components for tables and trees
  • File system utilities and content loading
  • System diagnostics and health checks

This comprehensive module provides everything you need to build powerful CLI tools, build systems, and automation workflows in Spry.

How is this guide?

Last updated on

On this page