Skip to content
0disoftPublic

About

Reliable CLI progress on stderr with clean stdout and versioned events.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

laqu

npm version Node.js 22+ MIT license

Progress for humans. Events for machines. stdout stays clean.

laqu gives Node.js and TypeScript CLIs live progress without corrupting piped JSON, CSV, file lists, or other caller-owned output. Human status goes to stderr by default; CI-safe text and versioned JSON/NDJSON events use the same task model when a terminal UI is not appropriate.

Use workflows for task lists, iterable and stream helpers for existing data pipelines, GitHub Actions output for CI diagnostics, and the NDJSON viewer to inspect a recorded run.

Laqu live progress with aligned task and progress columns

The preview is generated from the live renderer at 100 terminal columns. Column positions adapt to your terminal width; colors follow your terminal theme. Regenerate it with bun run build && node tools/generate-terminal-preview.mjs.

laqu 2.2.0  ────────────────────────────────────────────────────────────────────
TASK                    PROGRESS                 COUNT      %  STATE
────────────────────────────────────────────────────────────────────────────────
› install packages      [███████████████░░░░░]  76/100    76%  downloading
› build artifacts       [██████████░░░░░░░░░░]  49/100    49%  bundling
› publish preview       [░░░░░░░░░░░░░░░░░░░░]   0/100     0%  waiting
────────────────────────────────────────────────────────────────────────────────
3 running

Live terminals use shared task, progress, count, percentage, and state columns with 20-cell bars. At fewer than 64 columns, tasks return to compact rows. Headers, dividers, and the lifecycle summary appear when at least seven rows are available; smaller row budgets keep task rows and the hidden-task count. Long fields are clipped by terminal cell width, including CJK and emoji. Defaults use › for running tasks, ○ for pending tasks, and █ / ░ for progress; theme overrides still apply. The summary uses actual task states, including completed tasks removed by retention. A caller message such as waiting does not change a running task's lifecycle state.

Failed tasks take priority over active and successful tasks when rows are limited. Live output also records each failure's full title, message, and detail in scrollback, wrapped to terminal width. Unchanged failure diagnostics are emitted once; a changed cause produces a new diagnostic, and later screen redraws or task retention do not erase already-written scrollback. Standalone indeterminate tasks use the theme's indeterminate symbol at every terminal width; mixed describes an aggregate containing children with unknown progress.

The live header displays the package version, for example laqu 2.2.0. Builds embed the version from package.json, so bundled CLIs do not need Laqu's package files at runtime to display it. Interactive human output on the default stderr checks npm's public registry for a newer stable version in the background. A successful check is cached for 24 hours in the user's cache directory (%LOCALAPPDATA%/laqu on Windows, $XDG_CACHE_HOME/laqu or ~/.cache/laqu elsewhere). Checks use a 1.5-second network timeout, accept at most 8 KiB, and never wait during flush() or close(). Offline failures are silent; closing the last subscribed runtime cancels pending work. At most one notice is displayed per process, through live scrollback on the status stream:

Update available: laqu 2.2.0 → 2.3.0. Run npm install @0disoft/laqu@latest

CI, dumb terminals, non-TTY streams, plain, JSON/NDJSON, and silent output do not check. Set checkForUpdates: false to disable checks. Custom status streams default to no checks; checkForUpdates: true enables them only for a live human TTY outside CI. The request sends the package name and installed version, with no task titles, messages, or logs.

stdout remains available for the result:

{ "artifact": "dist/laqu.js", "status": "ready" }

Install

npm install @0disoft/laqu
pnpm add @0disoft/laqu
bun add @0disoft/laqu

The published package targets Node.js 22+ and does not require Bun, Deno, Rust, native addons, WASM, or C++ bindings at runtime.

Quick Start

import { createLaqu } from "@0disoft/laqu";

const progress = createLaqu();

const result = await progress.task("build", { total: 3 }, async (task) => {
  task.advance(1);
  task.setMessage("typecheck passed");
  task.advance(1);
  task.setMessage("bundle written");
  task.advance(1);
  return { artifact: "dist/laqu.js", status: "ready" };
});

await progress.close();
process.stdout.write(`${JSON.stringify(result)}\n`);

When this command runs interactively, progress animates on stderr. When stdout is redirected, the redirected file contains only the JSON result. In CI or a pipe, human progress automatically falls back to stable append-only lines.

Pick The Output Your Consumer Needs

Consumer Configuration Output behavior
Person in a terminal default live progress on stderr
CI log or redirected terminal default append-only progress on stderr
Event collector { format: "ndjson" } versioned events on stderr
Data pipeline default plus caller writes stdout clean caller-owned stdout

The output format, terminal capability, output target, and progress policy remain independent. You can change one without pretending that terminal detection, serialization, and destination are the same decision.

Runnable Examples

Run an example after installing dependencies:

bun run example:clean-stdout > result.json

The progress remains visible in the terminal while result.json stays parseable JSON.

Scoped Tasks

import { createLaqu } from "@0disoft/laqu";

const progress = createLaqu();

await progress.task("download", { total: 100 }, async (task) => {
  task.setMessage("starting");
  task.advance(25);
  task.setDetail("chunk 1/4");
  task.advance(75);
});

await progress.close();

Scoped tasks mark themselves as succeeded when the callback resolves. If the callback throws, the task is marked failed and the original error is rethrown. If the task receives an aborted AbortSignal, it is marked cancelled and cleanup still runs.

task() preserves the callback's result even when its automatic status flush or deferred close fails with LaquOutputError. Delivery errors remain sticky: explicitly await flush() or close() to observe them. An explicit delivery check inside the callback can still reject that callback.

Calling task.succeed() inside a scoped callback does not end the callback's lifetime. Its record stays available until the callback settles, even with retention.maxTerminalTasks: 0, so a later callback failure can correct the task status and summary without replacing the original error. Once the callback settles, its record follows the normal retention policy again.

Error messages are read once. If an error object cannot be inspected safely, progress uses a generic message while the scoped task still rethrows the original value.

Declarative Workflows

const results = await progress.workflow(
  [
    {
      title: "Compile",
      options: { total: 2 },
      task: async ({ context, task, signal, attempt }) => {
        signal.throwIfAborted();
        task.advance(1);
        context.artifacts.push("dist/app.js");
        task.advance(1);
        return { artifact: context.artifacts[0], attempt };
      },
    },
    { title: "Cache", skip: "cache hit", task: () => {} },
  ],
  { title: "Build", context: { artifacts: [] as string[] } },
);

Steps run in array order by default. Set concurrency: N to bound parallel execution; results always retain definition order. Each step has options for normal progress/unit/location metadata, and runs under a workflow parent task. The parent and steps participate in the existing renderers, events, retention and runtime close/draining. Await the workflow and close the runtime in finally.

skip accepts a boolean, a reason string (including an empty string), or an async context predicate. Only false runs the step; predicates are evaluated once when a worker reaches it. Predicate failure fails the step without retry. Callbacks receive shared context, a progress-only task handle, a signal, and one-based attempt. The runner owns step terminal states; child tasks created with task.child() remain caller-owned. Avoid depending on context writes from another parallel step; use sequential execution when a later step needs an earlier result.

The default exitOnError: true stops dispatch on the first exhausted failure, waits for active callbacks and their output cleanup, then rejects with the original failure value. Unstarted steps are omitted from events. Active steps are allowed to finish. exitOnError: false runs all steps and returns an array of { status, attempts, value? | error? | message? } results; the parent is still marked failed if any step failed. Error objects stay in memory and are not serialized into events.

Per-step retry: N permits N additional callback attempts, immediately and on the same step task. The default is zero; enable retries only for operations safe to repeat. Direct progress is reset to the step's initial options; shared context, external side effects and caller-owned children are not rolled back. Intermediate failures do not mark the step failed or emit failure annotations. Pass a workflow signal for cooperative cancellation: no further steps/retries start after abort, callbacks receive the signal, and rejection preserves its reason after active callbacks settle. Callbacks must observe it to interrupt an in-flight operation; the runner cannot preempt arbitrary promises. No signal handlers, dependency graphs, rollback or automatic process exit changes are added.

Manual Tasks

import { createProgressRuntime } from "@0disoft/laqu";

const progress = createProgressRuntime();
const build = progress.createTask("build", { total: 3 });

build.advance(1);
build.setMessage("typecheck");
build.advance(1);
build.setMessage("bundle");
build.advance(1);
build.succeed("done");

const optional = progress.createTask("optional cache warmup");
optional.skip("already warm");

await progress.close();

The API avoids ambiguous calls such as update(42). Use setCompleted(42) for absolute progress and advance(42) for a delta.

Counters must remain finite: an overflowing advance() throws before changing task state. Large finite child weights remain supported, including after completed children are pruned. Manual tasks also honor TaskOptions.signal; aborting the signal marks the task cancelled with the message aborted. Use task.skip(message) for intentionally skipped work such as cache hits, disabled feature branches, or already up-to-date steps.

After progress.close() starts, the runtime stops accepting new root tasks, caller logs, and manual task handle updates. Scoped task callbacks that were already running may finish their own task tree; unfinished manual tasks are cancelled before the final summary. Create a new runtime for later progress output.

Abort signals still cancel their tasks safely while close waits for scoped callbacks, including manual root and child tasks. This internal cancellation does not permit new caller mutations.

Units and Formatters

Use task units to make counts and speed readable without changing the measured numbers:

const download = progress.createTask("Download", {
  total: 80_000_000,
  unit: "bytes",
  byteBase: 1000, // default: kB / MB / GB; 1024 selects KiB / MiB / GiB
});
await pipeline(source, progress.meter(download), destination);

With timing: true, a live row can show 12.4 MB / 80 MB, 3.1 MB/s, and ETA 22s. Other unit names, such as unit: "items", display 2 / 10 items and 5 items/s. Units belong to individual tasks and are not inherited by children. Aggregate parents do not combine child counts or rates into their own unit. formatBytes(value, base?) is also exported from the root.

Override human COUNT and RATE text per task:

const task = progress.createTask("Index", {
  total: 100,
  unit: "files",
  formatters: {
    count: ({ current, total }) => `${current} of ${total} files`,
    rate: ({ ratePerSecond }) => `${ratePerSecond.toFixed(1)} files/s`,
  },
});

Callbacks receive raw numeric values, optional unit, and resolved byteBase. A counter's total is absent. Rate formatters run only when a measured rate exists. Keep callbacks pure: layout may call them more than once. Text is sanitized and clipped to terminal cell widths; thrown errors or non-string returns fall back to the built-in formatter. COUNT/RATE columns expand within the available width, while plain output retains full text.

JSON/NDJSON keeps original counts and timing numbers and adds optional task.unit and task.byteBase metadata. Formatter callbacks never run for machine output and are not serialized. These additive fields retain schema version 1. See examples/bytes.mjs.

ETA, Speed, and Elapsed Time

Opt in to timing without changing your task updates:

const progress = createLaqu({ timing: true });
// Or select just the columns you need:
// const progress = createLaqu({ timing: ["eta", "elapsed"] });

for await (const item of progress.iterate(items, { title: "Process items" })) {
  await processItem(item);
}
await progress.close();

Wide live ledgers add ETA, RATE, and ELAPSED columns. Narrower ledgers prioritize ETA, then rate, then elapsed; compact rows between 32 and 63 columns reserve space for ETA when selected. Extremely narrow terminals retain the existing compact progress row. Live timing refreshes once per second even while progress is idle. Plain output includes selected metrics on ordinary task output without generating periodic CI log lines. Timing is off by default.

Speed is direct count units per second (/s), using a time-weighted moving average over the recent five seconds, including idle time. A byte meter therefore measures bytes/s; an iterable measures items/s. Counts at task creation are a baseline, not work performed during this run. ETA needs a known total and positive speed. Samples shorter than 250 ms, resets, and total changes suppress the estimate until new samples are available. Unavailable values display --; after a five-second stall speed falls to zero and ETA is hidden. Completed tasks freeze elapsed time and stop estimating ETA. Child aggregates and ratio-only tasks show elapsed time without inventing a shared count unit or throughput.

With timing enabled, JSON/NDJSON task events also include optional task.timing:

{ "elapsedMs": 2000, "ratePerSecond": 25, "etaMs": 2000 }

Unknown rate/ETA fields are omitted. These additive fields keep event schema version 1; consumers should continue to ignore unfamiliar fields. See examples/timing.mjs.

Iterables and Streams

Wrap a synchronous or asynchronous iterable without manually updating a task:

for await (const item of progress.iterate(items, { title: "Process items" })) {
  await processItem(item);
}

The task starts lazily on the first next(). Arrays supply their length automatically; other iterables are counters unless you pass total. Each yielded item advances the count by one. Values are awaited using normal for await semantics. Exhaustion succeeds; source errors fail and rethrow the original error. break, return, and errors in the consumer's loop body close the source iterator and cancel the task: an iterator cannot distinguish those consumer exit causes. signal checks cancellation before the first pull and between items, rethrowing signal.reason; it does not interrupt a pending source next(). Pass the same signal to an interruptible source.

For Node.js streams, insert a metering Transform into a pipeline:

import { pipeline } from "node:stream/promises";

const download = progress.createTask("Download", { total: contentLength });
await pipeline(source, progress.meter(download), destination);

meter() forwards content with normal stream backpressure and counts bytes by default. Use { objectMode: true } to count objects, or size: (chunk) => number for custom units. Sizes must be finite and non-negative. highWaterMark controls buffering; signal aborts the meter. The meter succeeds when its input ends, fails on stream errors, and cancels on abort or premature destruction. Success measures passage through the meter, not completion of a downstream upload or file commit. Use an enclosing task() when that final operation needs its own status.

Prefer pipeline() to bare .pipe() so source/destination errors destroy every stage. Await the iteration or pipeline before closing the runtime. Neither helper closes the runtime for you. See examples/iterable-stream.mjs for a runnable example.

Logs

const progress = createLaqu();

progress.log("cache hit");
await progress.close();

Logs are separate scrollback records. They are not rendered as task rows and they pass through the same output coordinator as progress frames so live regions and log lines do not corrupt each other.

Live logs wrap to the current terminal width and preserve the full sanitized message, including trailing error causes and long file paths. They are emitted once and do not consume the task row budget; later redraws do not repeat previously emitted logs.

Process Lifecycle

laqu does not install process-level signal or exception handlers by default. Applications that already own shutdown should keep the default and call progress.close() from their own cleanup path.

Short-lived CLI commands that want laqu to flush progress output during SIGINT, SIGTERM, uncaughtException, or unhandledRejection can opt in:

const progress = createLaqu({
  manageProcessLifecycle: true,
});

Fatal process events do not wait for application work to finish. The runtime cancels unfinished tasks, makes a best-effort terminal cleanup for up to 250 milliseconds, and then re-delivers the original signal or exception.

Testing CLIs

Use the optional testing entrypoint with Node's test runner, Vitest, or any runner that handles standard assertion errors:

import { assertSummaryEvent, assertTaskEvent, createTestProgress } from "@0disoft/laqu/testing";

const harness = createTestProgress({ timing: true });
try {
  const task = harness.runtime.createTask("Download", { total: 10 });
  harness.clock.advance(1_000);
  task.advance(2);
  await harness.flush();
  assertTaskEvent(harness.events(), {
    title: "Download",
    status: "running",
    ownProgress: { current: 2 },
    timing: { elapsedMs: 1_000, ratePerSecond: 2, etaMs: 4_000 },
  });
  task.succeed();
  await harness.close();
  assertSummaryEvent(harness.events(), { total: 1, succeeded: 1, failed: 0 });
} finally {
  await harness.close();
}

The harness creates a real runtime with private captured streams and a manual measurement clock. Default mode: "ndjson" enables events(); await harness flush() before task assertions and close() before summary assertions. assertTaskEvent() checks the latest state for the selected task, with partial progress/timing expectations. Duplicate titles require selection by id. assertSummaryEvent() checks selected counts in the final summary; both return the matched event.

Use mode: "plain" for text assertions or mode: "live" for fake-TTY resize/backpressure checks. The clock controls measurements without running timers, and direct process.stdout writes remain outside the harness. See the consumer testing guide for stream injection, capture limits, duplicate task titles and cleanup. These utilities add no runtime dependencies.

Public Imports

The root import exposes the stable runtime API and common helpers:

import { createLaqu, displayWidth } from "@0disoft/laqu";

Focused subpath exports are available for narrower consumers:

import { LAQU_EVENT_SCHEMA_VERSION } from "@0disoft/laqu/events";
import { compileTheme } from "@0disoft/laqu/theme";
import { displayWidth } from "@0disoft/laqu/width";
import { createTestProgress } from "@0disoft/laqu/testing";

Output Contract

By default:

  • stdout is reserved for user data such as JSON, NDJSON, CSV, file lists, or binary output.
  • stderr is used for progress, status, logs, and machine-readable progress events.
  • human live rendering is enabled only when the status stream is a TTY and the environment is not CI.
  • one runtime owns a status stream at a time. Creating another runtime with the same stream before the first closes throws LAQU_OUTPUT_STREAM_IN_USE; share a runtime's task tree or supply separate streams.
  • CI, pipe, dumb terminal, and non-TTY output fall back to plain append rendering unless a different policy is requested.
  • plain append rendering preserves task creation and terminal transitions and full sanitized text for retained logs; frequent progress changes may be combined. maxRows and terminal-width truncation apply only to live rendering. Live rendering prioritizes failed tasks followed by active tasks, never exceeds the configured or available terminal rows, and redraws from current terminal dimensions when the status stream emits resize.
  • JSON/NDJSON progress events do not go to stdout unless the caller explicitly passes a separate status stream that points there.
const progress = createLaqu({
  format: "ndjson",
  progressPolicy: "jsonl",
  retention: { maxLogs: 1000, maxTerminalTasks: 1000 },
  stderr: process.stderr,
});

Machine-readable progress events use a versioned schema. format: "json" writes one parseable JSON array when the runtime closes; format: "ndjson" and progressPolicy: "jsonl" write newline-delimited event objects as work progresses. Plain and machine-readable output preserve task creation, terminal transitions, and retained logs in mutation order. Pending task and log records are bounded to 4096; exceeding the limit reports LAQU_OUTPUT_BUFFER_OVERFLOW instead of silently dropping task transitions. retention.maxLogs may intentionally omit older logs that have not yet been emitted.

Synchronous bursts are drained in batches when the status stream can complete writes immediately. For large loops using asynchronous or backpressured streams, yield regularly so delivery can finish:

for (let index = 0; index < items.length; index++) {
  progress.createTask(`item ${index}`).succeed();
  if ((index + 1) % 500 === 0) await progress.flush();
}
await progress.close();

Plain and machine-readable frames are queued in order while the status stream is backpressured. The queue is bounded to 4096 pending frames. A write exception, stream termination, drain timeout, unsupported backpressure contract, or buffer overflow becomes a sticky LaquOutputError returned by flush() and close(); output is never reported as successfully flushed after such a failure. Live rendering keeps only the latest screen frame while preserving queued scrollback.

Consumed plain and JSON frames are released immediately. Empty queue slots are periodically compacted even if continuous backpressure keeps the queue from becoming empty, so already-written frames do not accumulate across a long-running command.

For Node.js Writable status streams, flush() and close() also wait for completion of the writes submitted by laqu. A write that does not complete within the output timeout reports LAQU_OUTPUT_WRITE_TIMEOUT. If that write or best-effort cursor cleanup fails after close, its matching Writable error is handled without replacing the original output failure or leaving a persistent error listener. Existing caller error listeners and unrelated errors keep their behavior. Custom StreamTarget implementations without Node's write callback retain the write() and drain contract; laqu does not close caller-owned streams.

import { LaquOutputError } from "@0disoft/laqu";

try {
  await progress.close();
} catch (error) {
  if (error instanceof LaquOutputError) {
    console.error(error.code);
  }
  throw error;
}

The runtime retains the newest 1000 log records and 1000 terminal task records by default, bounding record counts for long-running commands. These limits do not cap the bytes in one log or output frame. Set retention.maxLogs or retention.maxTerminalTasks to smaller non-negative integers when only the latest output window should be rendered or emitted. Terminal task pruning affects retained task rows and task events only after the terminal task has been snapshotted for rendering; summary events keep lifecycle counts for all tasks created by the runtime. Task event fields such as parentId, message, and detail are omitted when they are absent. task.progress is the displayed aggregate across children. task.ownProgress preserves this task's direct input: a determinate value includes current and total, while a counter includes current without inventing a percentage. Consumers should ignore fields they do not use. Human output shows current/total beside the bar for a standalone determinate task, or its current count without a percentage when no total is known. Parent task rows keep the child aggregate. Silent and never-output policies prune completed task records without waiting for an output snapshot. Their flush() calls apply retention and check sticky stream errors without constructing the task tree snapshot or a rendering frame. Scoped callbacks still preserve their results and errors.

{
  "schema": "laqu.event",
  "version": 1,
  "type": "task",
  "task": {
    "id": "task-1",
    "title": "download",
    "status": "running",
    "depth": 0,
    "progress": {
      "kind": "ratio",
      "ratio": 0.5,
      "overrun": false
    },
    "ownProgress": {
      "kind": "determinate",
      "current": 50,
      "total": 100,
      "ratio": 0.5,
      "overrun": false
    }
  }
}

Event schema version 1 is exported as LAQU_EVENT_SCHEMA_VERSION.

The selection axes are independent:

  • format: human, json, or ndjson
  • streamCapability: tty, ci, pipe, or dumb
  • output target: stderr by default or an explicit statusStream
  • progressPolicy: auto, always, never, plain, jsonl, or silent

Themes

Themes are token-first:

const progress = createLaqu({
  theme: {
    successSymbol: "ok",
    runningSymbol: ">",
    progressComplete: "=",
    overflowMarker: "...",
  },
});

Theme tokens are semantic: success symbols, running symbols, progress glyphs, indentation, gaps, and overflow markers. Slot-level formatting should return safe renderable segments rather than raw strings with cursor movement.

dangerouslyRawAnsi() exists as an escape hatch for callers that need raw ANSI. It can break width measurement, fallback rendering, and reset guarantees if used incorrectly, so keep it isolated.

Width And ANSI

laqu includes a pure TypeScript width engine:

import { displayWidth, truncateToColumns, wrapToColumns } from "@0disoft/laqu";

displayWidth("\u001b[31m한글\u001b[0m"); // 4
truncateToColumns("👩‍💻 building", 8, { overflowMarker: "..." });
wrapToColumns("abcd한글", 4);

ANSI/control sequences are tokenized as zero-width. Text is segmented by grapheme; static East Asian Width ranges handle wide/fullwidth and ambiguous characters, while Node's Unicode properties distinguish marks, default emoji presentation, VS15/VS16, keycaps, flags, and ZWJ clusters. Ambiguous width defaults to one column unless overridden.

wrapToColumns() recalculates a tab's stop from column zero when that tab moves to a new line. If the expanded tab is wider than a line, its spaces are split across lines within the column budget. ANSI sequences, grapheme boundaries, and explicit newlines remain intact. Wrapping consumes graphemes incrementally, without an intermediate array containing every grapheme. The returned lines and input text still require memory proportional to their size.

View NDJSON Records

The installed package provides a laqu executable:

laqu view events.ndjson
laqu view - --plain
laqu view events.ndjson --max-rows 8 --max-tasks 2000

view accepts one UTF-8 NDJSON file or reads stdin when the file is omitted or -. A TTY stdin requires a file, so accidental interactive invocation does not wait silently. Use -- before a filename beginning with a dash. In this repository, run bun run build and then node dist/cli.js view events.ndjson. laqu --help prints usage to stdout; the viewer itself writes only to stderr. See examples/view.mjs for a complete capture-and-view example.

Task hierarchy, logs, byte units, failure details and final counts use the existing renderers. Live terminals redraw as records arrive and on resize; --plain, CI and dumb terminals use append-only text. ETA, throughput, elapsed and aggregate progress come directly from the recording; the viewer does not recalculate them using playback speed. It reads available records immediately, without timestamp delays or file-follow polling. SIGINT/SIGTERM close input and restore the cursor.

Inputs must contain one schema-v1 run with task/log/summary events. Extra fields are ignored; older v1 task records without ownProgress use the recorded aggregate as a fallback. Invalid JSON, versions, numeric fields, cyclic/inconsistent trees and records after a final summary fail with line numbers. Input record contents are not echoed in parse errors. Terminal controls and workflow command markers in records are sanitized/neutralized when displayed.

The default limit is 1000 retained tasks (--max-tasks adjusts it), 200 retained logs and 16 MiB of retained source payload. Older terminal task rows/logs may be removed; a final source summary still supplies lifetime counts. If active tasks exceed limits the viewer fails explicitly. Records are limited to 1 MiB and hierarchy depth to 256. Task text fields are limited to 65,536 UTF-16 code units. Without a final summary the output explicitly labels its retained counts as incomplete.

Exit 0 means valid input was viewed, including recordings with failed tasks. Argument, input and output errors exit 2; SIGINT/SIGTERM exit 130/143. The CLI does not change recorded task states or add a public replay API to the library root.

GitHub Actions Output

Opt in to GitHub Actions commands while keeping caller stdout clean:

const progress = createLaqu({ githubActions: true });
const task = progress.createTask("Compile", {
  location: { file: "src/app.ts", line: 12, column: 3 },
});
task.fail(new Error("Missing configuration"));
await progress.close();

Human status output becomes append-only on the status stream. One runtime-wide log group encloses concurrent tasks; each changed failure produces an error annotation with optional source location. Locations also work on scoped tasks and trackProcess() and are preserved in task events. Annotations do not post PR comments or change your process exit code: applications still own failure exit status.

On close, a Markdown table of task results and elapsed times is appended to GITHUB_STEP_SUMMARY when that environment variable is present. Lifetime counts remain accurate after task retention; the table retains at most 200 recently observed tasks, with bounded text per cell. Missing summary paths are skipped; a write failure rejects close with LAQU_OUTPUT_SUMMARY_FAILED.

const progress = createLaqu({
  githubActions: { group: "Build and test", summary: true },
  // group: false disables grouping; summary: false disables the file write.
  // summary: "/path/to/summary.md" overrides the environment path.
});

GitHub mode is off by default and is not automatically enabled by CI. JSON/NDJSON and silent/never policies take precedence and do not emit commands or write summaries. Command values/properties are escaped, and ordinary task/log text neutralizes both current and legacy runner command markers with zero-width separators. The runner handles status commands from stderr as well as stdout; see the runner implementation and workflow command reference. See examples/github-actions.mjs.

Child Process Output

Attach a freshly spawned process to the task UI while keeping its result out of status logs:

import { spawn } from "node:child_process";

const child = spawn(process.execPath, ["build.mjs"], {
  stdio: ["ignore", "pipe", "pipe"],
});
const result = await progress.trackProcess(child, {
  title: "Build",
  stdout: "capture",
  // stderr defaults to "log"
});
await progress.close();
process.stdout.write(result.stdout!);

stdout is required: select "capture", "log", or "ignore". stderr accepts the same modes and defaults to "log". Capture returns optional Buffer fields in { exitCode, signal, stdout?, stderr? }, preserving binary bytes. Log mode decodes UTF-8 lines and uses the runtime's coordinated status stream; ignore mode drains without retaining output. The helper never writes parent stdout. Both child output streams must be piped. Attach immediately after spawning, before consuming its output; late attachment after stdio closes and duplicate trackers reject. Compatible Node ChildProcess objects can come from an existing process library; spawning and stdin remain caller-owned.

Exit code zero succeeds only after child close and output consumption. Nonzero exit and external signal termination reject with exported LaquProcessError (LAQU_PROCESS_EXIT or LAQU_PROCESS_SIGNAL) and a result containing captured output. Native spawn/I/O errors retain their original identity. runtime.close() waits for tracked work and its trailing logs.

maxBuffer limits combined captured bytes (default 1 MiB); exceeding it rejects with LAQU_PROCESS_OUTPUT_LIMIT and the partial captured result at the limit. Log lines are split into fragments of at most maxLineLength UTF-16 code units (default 16 KiB, minimum 2), keeping surrogate pairs together. Log forwarding yields to status delivery in batches; ordinary retention/output failure policies still apply.

Pass signal to cancel: the task becomes cancelled, the promise rejects with signal.reason, and the helper sends SIGTERM to the direct child, followed by SIGKILL after killTimeoutMs (default 1000 ms) if it has not closed. Output/stream failure also stops the direct child. Process trees, interactive terminal ownership, and application-wide shutdown remain caller-owned. On failure the helper closes its read ends, including pipes held open by descendants after the direct child has exited. Use a caller-controlled abort signal or AbortSignal.timeout() for commands with a time budget. Validation errors leave the supplied child under caller control.

For interactive commands, close the runtime before handing over the terminal or use plain/log output. The tracked process path uses piped output rather than shared stdio: "inherit". See examples/process.mjs for a runnable local example.

Documentation And Support

Development

bun install
bun run check
bun run pack:check
bun run example:basic

bun run check runs strict typecheck, OXC lint, OXC format check, Node.js built-in tests, and build output generation. bun run pack:check builds a real package tarball, installs it into a new temporary project without registry access or lifecycle scripts, executes ESM imports, and compiles a strict TypeScript consumer with dependency declaration checks enabled. CI runs both checks on Ubuntu, Windows, and macOS. The real PTY resize harness runs on Unix hosts; Windows runs the full stream, process-signal, renderer, and package suite without claiming ConPTY coverage. An additional blocking Ubuntu job runs the same package and packed-consumer checks on the minimum supported runtime, Node.js 22. The three-platform matrix runs on Node.js 24. bun run example:basic builds the package and runs a small live progress demo. Terminal scrollback keeps the final frame; watch the command while it runs to see the bar animate in place.

Release

GitHub Actions publishes npm releases from maintainer-created version tags. The tag must match package.json exactly, for example v2.2.0 for version 2.2.0.

git tag -a v2.2.0 -m "v2.2.0"
git push origin main v2.2.0

The npm package must define a Trusted Publisher connection for GitHub Actions with organization/user 0disoft, repository laqu, workflow filename release.yml, environment name npm, and npm publish permission. The publish job targets the GitHub npm environment and obeys its configured protection rules. Required reviewers or deployment restrictions, when configured, must be satisfied before it runs; repository workflow files do not establish those external settings.

Before tagging, wait for main-branch CI, including Node.js 22 and the three-platform checks. On a matching tag push, the release workflow verifies the tag, package metadata, curated changelog, tests, build and installed-tarball consumers with read-only repository permissions. After any configured environment approvals, the publish job repeats package verification on the tagged commit, packs the release tarball, retains that exact artifact and publishes it with OIDC provenance. A final job creates the GitHub Release from the matching CHANGELOG.md section. Confirm the run, registry version/dist-tag and published tarball before treating publication as complete.

About

Reliable CLI progress on stderr with clean stdout and versioned events.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages