UNPKG

tasuku

Version:

タスク — The minimal task runner

473 lines (330 loc) 12.9 kB
<p align="center"> <img src=".github/tasuku.svg"> <br> <i>The minimal task runner for Node.js</i> </p> ### Features - Task list with dynamic states - Parallel & nestable tasks - Unopinionated - Type-safe → [Try it out online](https://stackblitz.com/edit/tasuku-demo?file=index.js&devtoolsheight=50&view=editor) <sub>Found this package useful? Show your support & appreciation by [sponsoring](https://github.com/sponsors/privatenumber)! ❤️</sub> ## Install ```sh npm i tasuku ``` ## About タスク (Tasuku) is a minimal task runner for Node.js. You can use it to label any task/function so that its loading, success, and error states are rendered in the terminal. For example, here's a simple script that copies a file from path A to B. ```ts import { copyFile } from 'node:fs/promises' import task from 'tasuku' task('Copying file from path A to B', async ({ setTitle }) => { await copyFile('/path/A', '/path/B') setTitle('Successfully copied file from path A to B!') }) ``` Running the script will look like this in the terminal: <img src=".github/media/basic.gif"> ## Usage ### Task list Call `task(taskTitle, taskFunction)` to start a task and display it in a task list in the terminal. ```ts import task from 'tasuku' task('Task 1', async () => { await someAsyncTask() }) task('Task 2', async () => { await someAsyncTask() }) task('Task 3', async () => { await someAsyncTask() }) ``` <img src=".github/media/task-list.gif"> #### Task states - **◽️ Pending** The task is queued and has not started - **🔅 Loading** The task is running - **⚠️ Warning** The task completed with a warning - **❌ Error** The task exited with an error - **✅ Success** The task completed without error <img src=".github/media/task-states.png"> ### Unopinionated You can call `task()` from anywhere. There are no requirements. It is designed to be as unopinionated as possible not to interfere with your code. The tasks will be displayed in the terminal in a consolidated list. You can change the title of the task by calling `setTitle()`. ```ts import task from 'tasuku' task('Task 1', async () => { await someAsyncTask() }) // ... someOtherCode() // ... task('Task 2', async ({ setTitle }) => { await someAsyncTask() setTitle('Task 2 complete') }) ``` <img src=".github/media/set-title.gif"> ### Task return values The return value of a task will be stored in the output `.result` property. If using TypeScript, the type of `.result` will be inferred from the task function. ```ts const myTask = await task('Task 2', async () => { await someAsyncTask() return 'Success' }) console.log(myTask.result) // 'Success' ``` ### Nesting tasks Tasks can be nested indefinitely. Nested tasks will be stacked hierarchically in the task list. ```ts await task('Do task', async ({ task }) => { await someAsyncTask() await task('Do another task', async ({ task }) => { await someAsyncTask() await task('And another', async () => { await someAsyncTask() }) }) }) ``` <img src=".github/media/nested.gif"> ### Collapsing nested tasks Call `.clear()` on the returned task API to collapse the nested task. ```ts await task('Do task', async ({ task }) => { await someAsyncTask() const nestedTask = await task('Do another task', async ({ task }) => { await someAsyncTask() }) nestedTask.clear() }) ``` <img src=".github/media/collapse.gif"> ### Grouped tasks Tasks can be grouped with `task.group()`. Pass in a function that returns an array of tasks to run them sequentially. This is useful for displaying a queue of tasks that have yet to run. ```ts const groupedTasks = await task.group(task => [ task('Task 1', async () => { await someAsyncTask() return 'one' }), task('Waiting for Task 1', async ({ setTitle }) => { setTitle('Task 2 running...') await someAsyncTask() setTitle('Task 2 complete') return 'two' }) // ... ]) console.log(groupedTasks) // [{ result: 'one' }, { result: 'two' }] ``` <img src=".github/media/grouped.gif"> ### Running tasks in parallel You can run tasks in parallel by passing in `{ concurrency: n }` as the second argument in `task.group()`. ```ts const api = await task.group(task => [ task( 'Task 1', async () => await someAsyncTask() ), task( 'Task 2', async () => await someAsyncTask() ) // ... ], { concurrency: 2 // Number of tasks to run at a time }) api.clear() // Clear output ``` <img src=".github/media/parallel.gif"> Alternatively, you can also use the native `Promise.all()` if you prefer. The advantage of using `task.group()` is that you can limit concurrency, displays queued tasks as pending, and it returns an API to easily clear the results. ```ts // No API await Promise.all([ task( 'Task 1', async () => await someAsyncTask() ), task( 'Task 2', async () => await someAsyncTask() ) // ... ]) ``` ## API ### task(taskTitle, taskFunction, options?) Returns a Promise that resolves with object: ```ts type TaskAPI = { // Result from taskFunction result: unknown // State of the task state: 'error' | 'warning' | 'success' // Warning message if state is 'warning', otherwise undefined warning: string | undefined // Error message if state is 'error', otherwise undefined error: string | undefined // Invoke to clear the results from the terminal clear: () => void } ``` #### taskTitle Type: `string` Required: true The name of the task displayed. #### taskFunction Type: ```ts type TaskFunction = (taskInnerApi: { task: createTask setTitle(title: string): void setStatus(status?: string): void setOutput(output: string | { message: string }): void setWarning(warning?: Error | string | false | null): void setError(error?: Error | string | false | null): void streamPreview: Writable startTime(): void stopTime(): number }) => Promise<unknown> ``` Required: true The task function. The return value will be stored in the `.result` property of the `task()` output object. #### task A task function to use for nesting. #### setTitle() Call with a string to change the task title. #### setStatus() Call with a string to set the status of the task. #### setOutput() Call with a string to set the output of the task. <img src=".github/media/task-output.png"> #### streamPreview A `Writable` stream for displaying live output below the task. Pipe a child process or any readable stream into it to show a scrolling preview of the output. Handles both `\n` (newline) and `\r` (carriage return) — programs like `curl` that use `\r` for in-place progress bars work out of the box. ```ts import { spawn } from 'node:child_process' import { pipeline } from 'node:stream/promises' await task('Download', async ({ streamPreview }) => { const child = spawn('curl', ['-o', '/dev/null', 'https://example.com/file']) await pipeline(child.stderr, streamPreview) }) ``` <img src=".github/media/stream-preview.gif"> By default, shows the last 5 lines. Use the `previewLines` option to change this. When there are more lines than the limit, a `(+ N lines)` indicator is shown. > [!NOTE] > `setOutput()` and `streamPreview` render independently. If both are used, static output appears above the stream preview. #### setWarning() Call with a string or Error instance to put the task in a warning state. Call with no argument (or a falsy value) to revert to loading state. #### setError() Call with a string or Error instance to put the task in an error state. Call with no argument (or a falsy value) to revert to loading state. Tasks automatically go into an error state when it catches an error in the task. <img src=".github/media/set-error.png"> #### startTime() Start or restart the elapsed time counter. Calling again resets to 0. Time is displayed after the status: `⠋ Task [status] (3s)` #### stopTime() Stop the elapsed time counter and return the elapsed milliseconds. The displayed time freezes at the stopped value. Useful for profiling task phases. ```ts await task('Multi-phase', async ({ startTime, stopTime, setStatus }) => { startTime() await phase1() const phase1Time = stopTime() setStatus('phase 2') startTime() await phase2() const phase2Time = stopTime() console.log(`Phase 1: ${phase1Time}ms, Phase 2: ${phase2Time}ms`) }) ``` #### options Type: `{ showTime?: boolean, previewLines?: number }` Optional task options. ##### previewLines Type: `number` Default: `5` Maximum number of lines to display in the `streamPreview` output (minimum 1). When the stream produces more lines, older lines scroll off and a `(+ N lines)` indicator shows the total. ##### showTime When `true`, automatically starts the elapsed time counter when the task begins. Equivalent to calling `startTime()` at the start of the task function. ```ts await task('Building', async () => { await build() }, { showTime: true }) // Output: ✔ Building (3s) ``` <img src=".github/media/elapsed-time.gif"> Time display: - Format: `(Xs)` for under a minute, `(Xm Ys)` for under an hour, `(Xh Ym)` for longer - Not shown if elapsed < 1 second - Freezes at final value when task completes ### task.group(createTaskFunctions, options) Returns a Promise that resolves with object: ```ts // The results from the taskFunctions type TaskGroupAPI = { // Result from taskFunction result: unknown // State of the task state: 'error' | 'warning' | 'success' // Invoke to clear the task result clear: () => void }[] & { // Invoke to clear ALL results clear: () => void } ``` #### createTaskFunctions Type: `(task) => Task[]` Required: true A function that returns all the tasks you want to group in an array. #### options Directly passed into [`p-map`](https://github.com/sindresorhus/p-map). ##### concurrency Type: `number` (Integer) Default: `1` Number of tasks to run at a time. ##### stopOnError Type: `boolean` Default: `true` When set to `false`, instead of stopping when a task fails, it will wait for all the tasks to finish and then reject with an aggregated error containing all the errors from the rejected promises. ##### maxVisible <p align="center"><img src=".github/media/max-visible.gif" width="600"></p> Type: `number | ((terminalHeight: number) => number)` Default: Responsive to terminal height (rows - 2, minimum 5) Maximum number of lines to display in the task list. When there are more task lines than this limit, remaining tasks are hidden with a state breakdown (e.g., "(+ 3 loading, 5 queued, 4 completed)"). Active tasks are always prioritized over pending and completed ones. This accounts for nested subtasks which add extra lines. Can be a fixed number or a function called on each render for responsive limits. By default, the limit is automatically lifted when all tasks complete and `.clear()` is called, revealing the full list. ```ts // Fixed limit await task.group(task => [...tasks], { concurrency: 5, maxVisible: 10 }) // Responsive limit (terminal height passed as parameter) await task.group(task => [...tasks], { concurrency: 5, maxVisible: height => height - 5 }) ``` ## FAQ ### What does "Tasuku" mean? _Tasuku_ or タスク is the phonetic Japanese pronounciation of the word "task". ### Why did you make this? I built _Tasuku_ as a lightweight task runner for scripts and CLI tools. It's designed to show task progress clearly without forcing a rigid structure on how you write your code. Big thanks to [listr](https://github.com/SamVerschueren/listr) and [listr2](https://github.com/cenk1cenk2/listr2), which inspired both the visuals and the idea—I've relied on them for years. But over time, I found their declarative approach too restrictive for my workflow, so I created something simpler and more flexible. _Tasuku_ uses its own minimal ANSI-based renderer for terminal output, giving you smooth `console.log()` integration with zero runtime dependencies. The rendering model was originally inspired by [ink](https://github.com/vadimdemedes/ink)'s approach to terminal UIs. ### Doesn't the usage of nested `task` functions violate ESLint's [no-shadow](https://eslint.org/docs/rules/no-shadow)? Yes, but it should be fine as you don't need access to other `task` functions aside from the immediate one. Put `task` in the allow list: - `"no-shadow": ["error", { "allow": ["task"] }]` - `"@typescript-eslint/no-shadow": ["error", { "allow": ["task"] }]` ## Sponsors <p align="center"> <a href="https://github.com/sponsors/privatenumber"> <img src="https://cdn.jsdelivr.net/gh/privatenumber/sponsors/sponsorkit/sponsors.svg"> </a> </p>