{"id":48437596,"url":"https://github.com/hugojosefson/log-fold","last_synced_at":"2026-04-06T14:02:28.542Z","repository":{"id":343068084,"uuid":"1175504681","full_name":"hugojosefson/log-fold","owner":"hugojosefson","description":"Collapsing log tree for CLI output, inspired by Docker Buildkit's progress display.","archived":false,"fork":false,"pushed_at":"2026-03-08T19:16:36.000Z","size":248,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-08T21:36:55.603Z","etag":null,"topics":["bun","cli","collapse","collapsible","collapsing","deno","fold","folding","levels","log","node","nodejs"],"latest_commit_sha":null,"homepage":"https://jsr.io/@hugojosefson/log-fold","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/hugojosefson.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-07T19:57:26.000Z","updated_at":"2026-03-08T19:17:32.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/hugojosefson/log-fold","commit_stats":null,"previous_names":["hugojosefson/log-fold"],"tags_count":15,"template":false,"template_full_name":null,"purl":"pkg:github/hugojosefson/log-fold","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hugojosefson%2Flog-fold","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hugojosefson%2Flog-fold/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hugojosefson%2Flog-fold/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hugojosefson%2Flog-fold/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/hugojosefson","download_url":"https://codeload.github.com/hugojosefson/log-fold/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/hugojosefson%2Flog-fold/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31475202,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-06T08:36:52.050Z","status":"ssl_error","status_checked_at":"2026-04-06T08:36:51.267Z","response_time":112,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bun","cli","collapse","collapsible","collapsing","deno","fold","folding","levels","log","node","nodejs"],"created_at":"2026-04-06T14:02:27.908Z","updated_at":"2026-04-06T14:02:28.533Z","avatar_url":"https://github.com/hugojosefson.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# log-fold\n\n[![JSR Version](https://jsr.io/badges/@hugojosefson/log-fold)](https://jsr.io/@hugojosefson/log-fold)\n[![JSR Score](https://jsr.io/badges/@hugojosefson/log-fold/score)](https://jsr.io/@hugojosefson/log-fold)\n[![CI](https://github.com/hugojosefson/log-fold/actions/workflows/release-and-publish.yaml/badge.svg)](https://github.com/hugojosefson/log-fold/actions/workflows/release-and-publish.yaml)\n\nCollapsing log tree for CLI output, inspired by Docker Buildkit's progress\ndisplay. Tasks collapse to a single line when done; running tasks expand to show\nsub-tasks and a tail window of log output. On error, the full log is dumped.\n\nWorks with Node.js, Deno, and Bun. Uses `node:` built-in modules — no\nruntime-specific APIs.\n\n## Installation\n\nTo add `@hugojosefson/log-fold` to your **Node.js** or **Bun** project with a\n**`package.json`**, run:\n\n```sh\nnpx jsr add @hugojosefson/log-fold\n```\n\nTo add it to a **Deno** project, run:\n\n```sh\ndeno add jsr:@hugojosefson/log-fold\n```\n\n## Usage\n\n### Basic example\n\nWrap units of work in `logTask()`. Call `log()` to append output lines. Nesting\nis automatic via `AsyncLocalStorage` — no context objects to pass around.\n\n```typescript\nimport { log, logTask } from \"@hugojosefson/log-fold\";\n\nawait logTask(\"All\", async () =\u003e {\n  await logTask(\"Install dependencies\", async () =\u003e {\n    log(\"npm install...\");\n    await new Promise((r) =\u003e setTimeout(r, 5000));\n    log(\"added 247 packages\");\n  });\n\n  // Concurrent tasks\n  await Promise.all([\n    logTask(\"Compile TypeScript\", async () =\u003e {\n      log(\"tsc --build\");\n      await new Promise((r) =\u003e setTimeout(r, 3000));\n    }),\n    logTask(\"Lint\", async () =\u003e {\n      log(\"eslint src/\");\n      await new Promise((r) =\u003e setTimeout(r, 2000));\n    }),\n  ]);\n\n  await logTask(\"Test\", async () =\u003e {\n    log(\"running 42 tests...\");\n    await new Promise((r) =\u003e setTimeout(r, 4000));\n    log(\"42 tests passed\");\n  });\n});\n```\n\nYou may run the above example with:\n\n```sh\ndeno run --reload jsr:@hugojosefson/log-fold/example-basic\n```\n\n### Concurrent tasks\n\nTasks inside `Promise.all` run simultaneously. Each branch has its own async\ncontext, so `log()` calls go to the correct task.\n\n```typescript\nimport { log, logTask } from \"@hugojosefson/log-fold\";\n\nawait logTask(\"CI\", async () =\u003e {\n  await logTask(\"Install\", () =\u003e {\n    log(\"npm install...\");\n  });\n\n  await Promise.all([\n    logTask(\"Compile\", () =\u003e {\n      log(\"tsc --build\");\n    }),\n    logTask(\"Lint\", () =\u003e {\n      log(\"eslint src/\");\n    }),\n  ]);\n});\n```\n\nYou may run the above example with:\n\n```sh\ndeno run --reload jsr:@hugojosefson/log-fold/example-concurrent-tasks\n```\n\n### Subprocess wrapper\n\n`runCommand` spawns a process, pipes stdout+stderr to the task log, and returns\ncaptured stdout. It auto-creates a `logTask` with the command as the title.\n\n```typescript\nimport { logTask, runCommand } from \"@hugojosefson/log-fold\";\n\nawait logTask(\"Run innocuous npm commands\", async () =\u003e {\n  await runCommand([\"npm\", \"search\", \"typescript\"]);\n  await runCommand(\"Printing the shell completion script for npm\", [\n    \"npm\",\n    \"completion\",\n  ]);\n});\n```\n\nThe first argument can be an explicit title or the command array. When passing\nthe command array directly, the title defaults to `command.join(\" \")`.\n\nNon-zero exit codes throw by default. Control this with `throwOnError`:\n\n| `throwOnError` | Behavior on non-zero exit          |\n| :------------- | :--------------------------------- |\n| `true`         | Throws an error (default)          |\n| `\"warn\"`       | Sets the subtask to warning status |\n| `false`        | Ignores the exit code              |\n\nYou may run the above example with:\n\n```sh\ndeno run --allow-run=npm --allow-env --reload jsr:@hugojosefson/log-fold/example-subprocess-wrapper\n```\n\n### Custom options\n\nPass session and per-task options to the top-level `logTask()`:\n\n```typescript\nimport { log, logTask } from \"@hugojosefson/log-fold\";\n\nawait logTask(\"Deploy\", { tailLines: 10, mode: \"plain\" }, async () =\u003e {\n  await logTask(\"Upload assets\", () =\u003e {\n    log(\"uploading...\");\n  });\n});\n```\n\nPer-task options (`tailLines`, `spinner`, `map`, `filter`) can be passed at any\nnesting level. Session options (`mode`, `output`, `tickInterval`) are only\nallowed at the top level — passing them to a nested `logTask()` throws.\n\nYou may run the above example with:\n\n```sh\ndeno run --reload jsr:@hugojosefson/log-fold/example-custom-options\n```\n\n### Warning, skipped, and dynamic title\n\n```typescript\nimport {\n  log,\n  logTask,\n  setCurrentTaskSkipped,\n  setCurrentTaskTitle,\n  setCurrentTaskWarning,\n} from \"@hugojosefson/log-fold\";\n\nawait logTask(\"Pipeline\", async () =\u003e {\n  // Warning status — task shows ⚠ instead of ✓\n  await logTask(\"Deploy\", async () =\u003e {\n    const result = await deploy();\n    if (result.deprecationWarnings.length \u003e 0) {\n      log(`${result.deprecationWarnings.length} deprecation warnings`);\n      setCurrentTaskWarning();\n    }\n  });\n\n  // Skip status — task shows ⊘ instead of ✓\n  await logTask(\"Build cache\", async () =\u003e {\n    if (await cacheExists()) {\n      setCurrentTaskSkipped();\n      return;\n    }\n    // ... build cache ...\n  });\n\n  // Dynamic title — updated on the next render tick\n  await logTask(\"Download\", async () =\u003e {\n    const files = await listFiles();\n    for (const [i, file] of files.entries()) {\n      setCurrentTaskTitle(`Download (${i + 1}/${files.length})`);\n      await downloadFile(file);\n    }\n  });\n});\n\n// stub functions\nfunction deploy() {\n  return Promise.resolve({ deprecationWarnings: [\"Something is old\"] });\n}\nfunction cacheExists() {\n  return Promise.resolve(true);\n}\nfunction listFiles() {\n  return Promise.resolve([\n    \"file1.txt\",\n    \"file2.txt\",\n    \"file3.txt\",\n    \"file4.txt\",\n    \"file5.txt\",\n  ]);\n}\nasync function downloadFile(_file: string) {\n  await new Promise((resolve) =\u003e {\n    setTimeout(resolve, 500);\n  });\n}\n```\n\nYou may run the above example with:\n\n```sh\ndeno run --reload jsr:@hugojosefson/log-fold/example-warning-skipped-dynamic-title\n```\n\n### Filtering and mapping log lines\n\nTransform or filter log lines before display and error dumps using `map` and\n`filter` task options. These compose with ancestor tasks — child transforms\napply first, then parent transforms.\n\n```typescript\nimport { log, logTask } from \"@hugojosefson/log-fold\";\n\n// Redact secrets — filtered lines are hidden from display AND error dumps\nawait logTask(\n  \"Deploy\",\n  { filter: (line) =\u003e !line.includes(\"SECRET\") },\n  () =\u003e {\n    log(\"connecting to server...\");\n    log(\"using token: SECRET_abc123\"); // hidden everywhere\n    log(\"deploy complete\");\n  },\n);\n\n// Rewrite paths — applies to display and error dumps\nawait logTask(\n  \"Build\",\n  { map: (line) =\u003e line.replace(/\\/home\\/user/g, \"~\") },\n  () =\u003e {\n    log(\"compiling /home/user/src/main.ts\"); // shown as \"compiling ~/src/main.ts\"\n  },\n);\n```\n\nYou may run the above example with:\n\n```sh\ndeno run --reload jsr:@hugojosefson/log-fold/example-filtering-mapping\n```\n\n### Stream piping with `logFromStream`\n\nPipe streams from any runtime's subprocess API (or any `ReadableStream`,\n`Readable`, or `AsyncIterable`) into the current task's log.\n\n```typescript\nimport { log, logFromStream, logTask } from \"@hugojosefson/log-fold\";\nimport { spawn } from \"node:child_process\";\n\n// Node.js child_process\nawait logTask(\"My process\", async () =\u003e {\n  const child = spawn(\"find\", [\".\", \"-type\", \"f\"]);\n  const _output = await logFromStream(child);\n});\n\nif (\"Deno\" in globalThis) {\n  // Deno.Command\n  try {\n    await logTask(\"Install npm deps\", async () =\u003e {\n      log(\"Create custom child process\");\n      const child = new Deno.Command(\"npm\", {\n        args: [\"install\"],\n        stdout: \"piped\",\n        stderr: \"piped\",\n      }).spawn();\n\n      await logTask(\"Pipe its output to the log\", async () =\u003e {\n        await logFromStream(child);\n      });\n\n      await logTask(\"Wait for custom process to end\", async () =\u003e {\n        const status = await child.status;\n        if (!status.success) {\n          throw new Error(JSON.stringify(status));\n        }\n      });\n    });\n  } catch {\n    console.error(\n      `\u003c\u003c\u003c 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: \u003e\u003e\u003e`,\n    );\n  }\n}\n// Single ReadableStream (e.g. fetch response)\nawait logTask(\"Fetch logs\", async () =\u003e {\n  const response = await fetch(\"https://example.com/logs\");\n  await logFromStream(response.body!);\n});\n```\n\n\u003e **StreamPair return semantics**: when you pass a process-like object (has\n\u003e `.stdout` and/or `.stderr`), both streams are piped to `log()` for display,\n\u003e but only **stdout lines** are collected in the return value. This matches the\n\u003e unix convention that stdout is structured output and stderr is diagnostic.\n\u003e Passing a single stream (e.g. `child.stdout` directly) returns all its\n\u003e content.\n\nYou may run the above example with:\n\n```sh\ndeno run --allow-run=find,npm --allow-net=example.com --allow-env --reload jsr:@hugojosefson/log-fold/example-stream-piping\n```\n\n## Options reference\n\n### Session options\n\nPassed to the top-level `logTask()` only.\n\n| Option         | Type                                           | Default          | Description                                   |\n| :------------- | :--------------------------------------------- | :--------------- | :-------------------------------------------- |\n| `mode`         | `\"tty\" \\| \"plain\" \\| \"auto\"`                   | `\"auto\"`         | Force TTY or plain mode, or auto-detect       |\n| `output`       | `WriteStream \\| { write(s: string): boolean }` | `process.stderr` | Output stream (TTY mode requires WriteStream) |\n| `tickInterval` | `number`                                       | `150`            | Render tick interval in ms                    |\n\n### Task options\n\nPassed at any nesting level. `tailLines` and `spinner` inherit from the nearest\nancestor that sets them. `map` and `filter` compose with ancestors (child first,\nthen parent).\n\n| Option      | Type                        | Default                | Description                                              |\n| :---------- | :-------------------------- | :--------------------- | :------------------------------------------------------- |\n| `tailLines` | `number`                    | `6`                    | Log tail lines to show for running tasks (0 = hide tail) |\n| `spinner`   | `Spinner`                   | dots from cli-spinners | Spinner animation for running tasks                      |\n| `map`       | `(line: string) =\u003e string`  | identity               | Transform each log line before display                   |\n| `filter`    | `(line: string) =\u003e boolean` | `() =\u003e true`           | Filter log lines (return `true` to show)                 |\n\n## Gotchas\n\n### `tailLines: 0` vs `filter: () =\u003e false`\n\nBoth suppress log output during execution, but they differ on error:\n\n| Option                | Tail window | Error dump |\n| :-------------------- | :---------- | :--------- |\n| `tailLines: 0`        | Hidden      | Shown      |\n| `filter: () =\u003e false` | Hidden      | Hidden     |\n\nUse `tailLines: 0` when you want a clean display but full logs on failure. Use\n`filter` when you need to redact content everywhere (including error dumps).\n\n### `map`/`filter` apply to error dumps too\n\nRaw log lines are always stored in `logLines[]` on the task node. When an error\ndump is rendered, lines pass through `composedFlatMap` (the composed\n`map`/`filter` chain). If you filter out lines containing secrets, those secrets\nare also redacted in error dumps.\n\n### Sequential top-level `logTask()` calls create independent sessions\n\nEach top-level `logTask()` call (outside any existing context) creates its own\nrender session with independent progress tracking and cursor management. To\nunify multiple top-level tasks under one session:\n\n```typescript\nawait logTask(\"All\", async () =\u003e {\n  await logTask(\"First\", async () =\u003e {/* ... */});\n  await logTask(\"Second\", async () =\u003e {/* ... */});\n});\n```\n\n### `LOG_FOLD_STRICT` environment variable\n\nWhen set (any non-empty value), `log()` outside a task context throws instead of\nfalling back to stderr. Useful during development to catch code paths that run\noutside a `logTask()` wrapper unintentionally. Libraries should not set this.\n\n## API\n\nFull API docs on\n[jsr.io/@hugojosefson/log-fold](https://jsr.io/@hugojosefson/log-fold).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhugojosefson%2Flog-fold","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fhugojosefson%2Flog-fold","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fhugojosefson%2Flog-fold/lists"}