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.

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.1.2  ────────────────────────────────────────────────────────────────────
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.1.2. 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.1.2 → 2.2.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.

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.

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.

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";

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.

Child Process Output

Do not mix child process output with live rendering through stdio: "inherit". Pipe child output through the parent process and write it with runtime.log(), close the runtime before handing the terminal directly to the child process, or run progress output in plain/log mode.

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.1.2 for version 2.1.2.

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

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 allowed. The GitHub repository must also define an npm environment with required reviewers and a deployment tag rule that allows only v*.*.* tags.

On a matching tag push, the workflow first verifies the tag, package metadata, tests, build, and installed-tarball consumer checks with read-only repository permissions. The publish job then waits on the npm environment gate, repeats package verification on the tagged commit, packs the release tarball, uploads that exact tarball as a retained workflow artifact, publishes the same tarball to npm with provenance through OIDC, and creates a GitHub Release from the matching CHANGELOG.md section.

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