Collapsing log tree for CLI output, inspired by Docker Buildkit's progress display. Tasks collapse to a single line when done; running tasks expand to show sub-tasks and a tail window of log output. On error, the full log is dumped.
Works with Node.js, Deno, and Bun. Uses node: built-in modules — no
runtime-specific APIs.
To add @hugojosefson/log-fold to your Node.js or Bun project with a
package.json, run:
npx jsr add @hugojosefson/log-foldTo add it to a Deno project, run:
deno add jsr:@hugojosefson/log-foldWrap units of work in logTask(). Call log() to append output lines. Nesting
is automatic via AsyncLocalStorage — no context objects to pass around.
import { log, logTask } from "@hugojosefson/log-fold";
await logTask("All", async () => {
await logTask("Install dependencies", async () => {
log("npm install...");
await new Promise((r) => setTimeout(r, 5000));
log("added 247 packages");
});
// Concurrent tasks
await Promise.all([
logTask("Compile TypeScript", async () => {
log("tsc --build");
await new Promise((r) => setTimeout(r, 3000));
}),
logTask("Lint", async () => {
log("eslint src/");
await new Promise((r) => setTimeout(r, 2000));
}),
]);
await logTask("Test", async () => {
log("running 42 tests...");
await new Promise((r) => setTimeout(r, 4000));
log("42 tests passed");
});
});You may run the above example with:
deno run --reload jsr:@hugojosefson/log-fold/example-basicTasks inside Promise.all run simultaneously. Each branch has its own async
context, so log() calls go to the correct task.
import { log, logTask } from "@hugojosefson/log-fold";
await logTask("CI", async () => {
await logTask("Install", () => {
log("npm install...");
});
await Promise.all([
logTask("Compile", () => {
log("tsc --build");
}),
logTask("Lint", () => {
log("eslint src/");
}),
]);
});You may run the above example with:
deno run --reload jsr:@hugojosefson/log-fold/example-concurrent-tasksrunCommand spawns a process, pipes stdout+stderr to the task log, and returns
captured stdout. It auto-creates a logTask with the command as the title.
import { logTask, runCommand } from "@hugojosefson/log-fold";
await logTask("Run innocuous npm commands", async () => {
await runCommand(["npm", "search", "typescript"]);
await runCommand("Printing the shell completion script for npm", [
"npm",
"completion",
]);
});The first argument can be an explicit title or the command array. When passing
the command array directly, the title defaults to command.join(" ").
Non-zero exit codes throw by default. Control this with throwOnError:
throwOnError |
Behavior on non-zero exit |
|---|---|
true |
Throws an error (default) |
"warn" |
Sets the subtask to warning status |
false |
Ignores the exit code |
You may run the above example with:
deno run --allow-run=npm --allow-env --reload jsr:@hugojosefson/log-fold/example-subprocess-wrapperPass session and per-task options to the top-level logTask():
import { log, logTask } from "@hugojosefson/log-fold";
await logTask("Deploy", { tailLines: 10, mode: "plain" }, async () => {
await logTask("Upload assets", () => {
log("uploading...");
});
});Per-task options (tailLines, spinner, map, filter) can be passed at any
nesting level. Session options (mode, output, tickInterval) are only
allowed at the top level — passing them to a nested logTask() throws.
You may run the above example with:
deno run --reload jsr:@hugojosefson/log-fold/example-custom-optionsimport {
log,
logTask,
setCurrentTaskSkipped,
setCurrentTaskTitle,
setCurrentTaskWarning,
} from "@hugojosefson/log-fold";
await logTask("Pipeline", async () => {
// Warning status — task shows ⚠ instead of ✓
await logTask("Deploy", async () => {
const result = await deploy();
if (result.deprecationWarnings.length > 0) {
log(`${result.deprecationWarnings.length} deprecation warnings`);
setCurrentTaskWarning();
}
});
// Skip status — task shows ⊘ instead of ✓
await logTask("Build cache", async () => {
if (await cacheExists()) {
setCurrentTaskSkipped();
return;
}
// ... build cache ...
});
// Dynamic title — updated on the next render tick
await logTask("Download", async () => {
const files = await listFiles();
for (const [i, file] of files.entries()) {
setCurrentTaskTitle(`Download (${i + 1}/${files.length})`);
await downloadFile(file);
}
});
});
// stub functions
function deploy() {
return Promise.resolve({ deprecationWarnings: ["Something is old"] });
}
function cacheExists() {
return Promise.resolve(true);
}
function listFiles() {
return Promise.resolve([
"file1.txt",
"file2.txt",
"file3.txt",
"file4.txt",
"file5.txt",
]);
}
async function downloadFile(_file: string) {
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
}You may run the above example with:
deno run --reload jsr:@hugojosefson/log-fold/example-warning-skipped-dynamic-titleTransform or filter log lines before display and error dumps using map and
filter task options. These compose with ancestor tasks — child transforms
apply first, then parent transforms.
import { log, logTask } from "@hugojosefson/log-fold";
// Redact secrets — filtered lines are hidden from display AND error dumps
await logTask(
"Deploy",
{ filter: (line) => !line.includes("SECRET") },
() => {
log("connecting to server...");
log("using token: SECRET_abc123"); // hidden everywhere
log("deploy complete");
},
);
// Rewrite paths — applies to display and error dumps
await logTask(
"Build",
{ map: (line) => line.replace(/\/home\/user/g, "~") },
() => {
log("compiling /home/user/src/main.ts"); // shown as "compiling ~/src/main.ts"
},
);You may run the above example with:
deno run --reload jsr:@hugojosefson/log-fold/example-filtering-mappingPipe streams from any runtime's subprocess API (or any ReadableStream,
Readable, or AsyncIterable) into the current task's log.
import { log, logFromStream, logTask } from "@hugojosefson/log-fold";
import { spawn } from "node:child_process";
// Node.js child_process
await logTask("My process", async () => {
const child = spawn("find", [".", "-type", "f"]);
const _output = await logFromStream(child);
});
if ("Deno" in globalThis) {
// Deno.Command
try {
await logTask("Install npm deps", async () => {
log("Create custom child process");
const child = new Deno.Command("npm", {
args: ["install"],
stdout: "piped",
stderr: "piped",
}).spawn();
await logTask("Pipe its output to the log", async () => {
await logFromStream(child);
});
await logTask("Wait for custom process to end", async () => {
const status = await child.status;
if (!status.success) {
throw new Error(JSON.stringify(status));
}
});
});
} catch {
console.error(
`<<< Swallowing error from "Install npm deps", because we expect "npm install" to fail if there is no "package.json", and so that the next example can run: >>>`,
);
}
}
// Single ReadableStream (e.g. fetch response)
await logTask("Fetch logs", async () => {
const response = await fetch("https://example.com/logs");
await logFromStream(response.body!);
});StreamPair return semantics: when you pass a process-like object (has
.stdoutand/or.stderr), both streams are piped tolog()for display, but only stdout lines are collected in the return value. This matches the unix convention that stdout is structured output and stderr is diagnostic. Passing a single stream (e.g.child.stdoutdirectly) returns all its content.
You may run the above example with:
deno run --allow-run=find,npm --allow-net=example.com --allow-env --reload jsr:@hugojosefson/log-fold/example-stream-pipingPassed to the top-level logTask() only.
| Option | Type | Default | Description |
|---|---|---|---|
mode |
"tty" | "plain" | "auto" |
"auto" |
Force TTY or plain mode, or auto-detect |
output |
WriteStream | { write(s: string): boolean } |
process.stderr |
Output stream (TTY mode requires WriteStream) |
tickInterval |
number |
150 |
Render tick interval in ms |
Passed at any nesting level. tailLines and spinner inherit from the nearest
ancestor that sets them. map and filter compose with ancestors (child first,
then parent).
| Option | Type | Default | Description |
|---|---|---|---|
tailLines |
number |
6 |
Log tail lines to show for running tasks (0 = hide tail) |
spinner |
Spinner |
dots from cli-spinners | Spinner animation for running tasks |
map |
(line: string) => string |
identity | Transform each log line before display |
filter |
(line: string) => boolean |
() => true |
Filter log lines (return true to show) |
Both suppress log output during execution, but they differ on error:
| Option | Tail window | Error dump |
|---|---|---|
tailLines: 0 |
Hidden | Shown |
filter: () => false |
Hidden | Hidden |
Use tailLines: 0 when you want a clean display but full logs on failure. Use
filter when you need to redact content everywhere (including error dumps).
Raw log lines are always stored in logLines[] on the task node. When an error
dump is rendered, lines pass through composedFlatMap (the composed
map/filter chain). If you filter out lines containing secrets, those secrets
are also redacted in error dumps.
Each top-level logTask() call (outside any existing context) creates its own
render session with independent progress tracking and cursor management. To
unify multiple top-level tasks under one session:
await logTask("All", async () => {
await logTask("First", async () => {/* ... */});
await logTask("Second", async () => {/* ... */});
});When set (any non-empty value), log() outside a task context throws instead of
falling back to stderr. Useful during development to catch code paths that run
outside a logTask() wrapper unintentionally. Libraries should not set this.
Full API docs on jsr.io/@hugojosefson/log-fold.