├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ └── FEATURE_REQUEST.md ├── example-0.gif ├── example-1.gif ├── example-2.gif ├── example-3.gif ├── example-4.gif ├── example-5.gif ├── example-6.gif ├── task-states.png ├── task-title-status-output.png ├── tasuku.svg └── workflows │ ├── package-size-report.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src ├── components │ ├── CreateApp.tsx │ ├── TaskListApp.tsx │ └── TaskListItem.tsx ├── index.ts ├── types.ts └── utils.ts ├── tests ├── tasuku.spec.ts ├── tasuku.test-d.ts └── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | labels: 'bug: pending triage' 5 | --- 6 | 7 | ## Bug description 8 | 17 | 18 | ## Reproduction 19 | 32 | 33 | ## Environment 34 | 35 | - tasuku version: 36 | - Operating System: 37 | - Node version: 38 | - Package manager (npm/yarn/pnpm) and version: 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: 'feature request' 5 | --- 6 | 7 | ## Is your feature request related to a problem? 8 | 13 | 14 | ## Describe the solution you'd like 15 | 20 | 21 | ## Describe alternatives you've considered 22 | 27 | 28 | ## Additional context 29 | 34 | -------------------------------------------------------------------------------- /.github/example-0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-0.gif -------------------------------------------------------------------------------- /.github/example-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-1.gif -------------------------------------------------------------------------------- /.github/example-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-2.gif -------------------------------------------------------------------------------- /.github/example-3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-3.gif -------------------------------------------------------------------------------- /.github/example-4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-4.gif -------------------------------------------------------------------------------- /.github/example-5.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-5.gif -------------------------------------------------------------------------------- /.github/example-6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/example-6.gif -------------------------------------------------------------------------------- /.github/task-states.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/task-states.png -------------------------------------------------------------------------------- /.github/task-title-status-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/privatenumber/tasuku/80e24570106071968e26c5d44b78800d8d2eeafd/.github/task-title-status-output.png -------------------------------------------------------------------------------- /.github/tasuku.svg: -------------------------------------------------------------------------------- 1 | 2 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/package-size-report.yml: -------------------------------------------------------------------------------- 1 | name: Package size report 2 | 3 | on: 4 | pull_request: 5 | branches: [master, develop] 6 | 7 | jobs: 8 | pkg-size-report: 9 | name: Package size report 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Package size report 17 | id: pkg-size-report 18 | uses: privatenumber/pkg-size-action@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | display-size: uncompressed, gzip 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version-file: .nvmrc 21 | 22 | - name: Setup pnpm 23 | uses: pnpm/action-setup@v4 24 | with: 25 | run_install: true 26 | 27 | - name: Build 28 | run: pnpm build 29 | 30 | - name: Test 31 | run: pnpm test 32 | 33 | - name: Release 34 | env: 35 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | run: pnpm dlx semantic-release 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | pull_request: 7 | branches: [master, develop] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version-file: .nvmrc 23 | 24 | - name: Setup pnpm 25 | uses: pnpm/action-setup@v4 26 | with: 27 | run_install: true 28 | 29 | - name: Type check 30 | run: pnpm type-check 31 | 32 | - name: Build 33 | run: pnpm build 34 | 35 | - name: Test 36 | run: pnpm test 37 | 38 | - name: Test Node.js v12 39 | run: pnpm --use-node-version=12.22.12 node ./tests/test.js 40 | 41 | - name: DTS test 42 | run: pnpm test:tsd 43 | 44 | - name: Lint 45 | run: pnpm lint 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Dependency directories 13 | node_modules/ 14 | 15 | # Output of 'npm pack' 16 | *.tgz 17 | 18 | # dotenv environment variables file 19 | .env 20 | .env.test 21 | 22 | # Cache 23 | .eslintcache 24 | 25 | # Distribution 26 | dist 27 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.13.0 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Hiroki Osame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | The minimal task runner for Node.js 5 |

6 | 7 | ### Features 8 | - Task list with dynamic states 9 | - Parallel & nestable tasks 10 | - Unopinionated 11 | - Type-safe 12 | 13 | → [Try it out online](https://stackblitz.com/edit/tasuku-demo?file=index.js&devtoolsheight=50&view=editor) 14 | 15 | Found this package useful? Show your support & appreciation by [sponsoring](https://github.com/sponsors/privatenumber)! ❤️ 16 | 17 | ## Install 18 | ```sh 19 | npm i tasuku 20 | ``` 21 | 22 | ## About 23 | タスク (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. 24 | 25 | For example, here's a simple script that copies a file from path A to B. 26 | 27 | ```ts 28 | import { copyFile } from 'fs/promises' 29 | import task from 'tasuku' 30 | 31 | task('Copying file from path A to B', async ({ setTitle }) => { 32 | await copyFile('/path/A', '/path/B') 33 | 34 | setTitle('Successfully copied file from path A to B!') 35 | }) 36 | ``` 37 | 38 | Running the script will look like this in the terminal: 39 | 40 | 41 | 42 | ## Usage 43 | ### Task list 44 | Call `task(taskTitle, taskFunction)` to start a task and display it in a task list in the terminal. 45 | 46 | ```ts 47 | import task from 'tasuku' 48 | 49 | task('Task 1', async () => { 50 | await someAsyncTask() 51 | }) 52 | 53 | task('Task 2', async () => { 54 | await someAsyncTask() 55 | }) 56 | 57 | task('Task 3', async () => { 58 | await someAsyncTask() 59 | }) 60 | ``` 61 | 62 | 63 | 64 | #### Task states 65 | - **◽️ Pending** The task is queued and has not started 66 | - **🔅 Loading** The task is running 67 | - **⚠️ Warning** The task completed with a warning 68 | - **❌ Error** The task exited with an error 69 | - **✅ Success** The task completed without error 70 | 71 | 72 | 73 | ### Unopinionated 74 | 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. 75 | 76 | The tasks will be displayed in the terminal in a consolidated list. 77 | 78 | You can change the title of the task by calling `setTitle()`. 79 | ```ts 80 | import task from 'tasuku' 81 | 82 | task('Task 1', async () => { 83 | await someAsyncTask() 84 | }) 85 | 86 | // ... 87 | 88 | someOtherCode() 89 | 90 | // ... 91 | 92 | task('Task 2', async ({ setTitle }) => { 93 | await someAsyncTask() 94 | 95 | setTitle('Task 2 complete') 96 | }) 97 | ``` 98 | 99 | 100 | 101 | ### Task return values 102 | The return value of a task will be stored in the output `.result` property. 103 | 104 | If using TypeScript, the type of `.result` will be inferred from the task function. 105 | 106 | ```ts 107 | const myTask = await task('Task 2', async () => { 108 | await someAsyncTask() 109 | 110 | return 'Success' 111 | }) 112 | 113 | console.log(myTask.result) // 'Success' 114 | ``` 115 | 116 | ### Nesting tasks 117 | Tasks can be nested indefinitely. Nested tasks will be stacked hierarchically in the task list. 118 | ```ts 119 | await task('Do task', async ({ task }) => { 120 | await someAsyncTask() 121 | 122 | await task('Do another task', async ({ task }) => { 123 | await someAsyncTask() 124 | 125 | await task('And another', async () => { 126 | await someAsyncTask() 127 | }) 128 | }) 129 | }) 130 | ``` 131 | 132 | 133 | 134 | ### Collapsing nested tasks 135 | Call `.clear()` on the returned task API to collapse the nested task. 136 | ```ts 137 | await task('Do task', async ({ task }) => { 138 | await someAsyncTask() 139 | 140 | const nestedTask = await task('Do another task', async ({ task }) => { 141 | await someAsyncTask() 142 | }) 143 | 144 | nestedTask.clear() 145 | }) 146 | ``` 147 | 148 | 149 | 150 | ### Grouped tasks 151 | Tasks can be grouped with `task.group()`. Pass in a function that returns an array of tasks to run them sequentially. 152 | 153 | This is useful for displaying a queue of tasks that have yet to run. 154 | 155 | ```ts 156 | const groupedTasks = await task.group(task => [ 157 | task('Task 1', async () => { 158 | await someAsyncTask() 159 | 160 | return 'one' 161 | }), 162 | 163 | task('Waiting for Task 1', async ({ setTitle }) => { 164 | setTitle('Task 2 running...') 165 | 166 | await someAsyncTask() 167 | 168 | setTitle('Task 2 complete') 169 | 170 | return 'two' 171 | }) 172 | 173 | // ... 174 | ]) 175 | 176 | console.log(groupedTasks) // [{ result: 'one' }, { result: 'two' }] 177 | ``` 178 | 179 | 180 | 181 | ### Running tasks in parallel 182 | You can run tasks in parallel by passing in `{ concurrency: n }` as the second argument in `task.group()`. 183 | 184 | ```ts 185 | const api = await task.group(task => [ 186 | task( 187 | 'Task 1', 188 | async () => await someAsyncTask() 189 | ), 190 | 191 | task( 192 | 'Task 2', 193 | async () => await someAsyncTask() 194 | ) 195 | 196 | // ... 197 | ], { 198 | concurrency: 2 // Number of tasks to run at a time 199 | }) 200 | 201 | api.clear() // Clear output 202 | ``` 203 | 204 | 205 | 206 | 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. 207 | 208 | ```ts 209 | // No API 210 | await Promise.all([ 211 | task( 212 | 'Task 1', 213 | async () => await someAsyncTask() 214 | ), 215 | 216 | task( 217 | 'Task 2', 218 | async () => await someAsyncTask() 219 | ) 220 | 221 | // ... 222 | ]) 223 | ``` 224 | 225 | ## API 226 | 227 | ### task(taskTitle, taskFunction) 228 | 229 | Returns a Promise that resolves with object: 230 | ```ts 231 | type TaskAPI = { 232 | // Result from taskFunction 233 | result: unknown 234 | 235 | // State of the task 236 | state: 'error' | 'warning' | 'success' 237 | 238 | // Invoke to clear the results from the terminal 239 | clear: () => void 240 | } 241 | ``` 242 | 243 | #### taskTitle 244 | Type: `string` 245 | 246 | Required: true 247 | 248 | The name of the task displayed. 249 | 250 | #### taskFunction 251 | Type: 252 | ```ts 253 | type TaskFunction = (taskInnerApi: { 254 | task: createTask 255 | setTitle(title: string): void 256 | setStatus(status?: string): void 257 | setOutput(output: string | { message: string }): void 258 | setWarning(warning: Error | string): void 259 | setError(error: Error | string): void 260 | }) => Promise 261 | ``` 262 | 263 | Required: true 264 | 265 | The task function. The return value will be stored in the `.result` property of the `task()` output object. 266 | 267 | 268 | #### task 269 | A task function to use for nesting. 270 | 271 | #### setTitle() 272 | Call with a string to change the task title. 273 | 274 | #### setStatus() 275 | Call with a string to set the status of the task. See image below. 276 | 277 | #### setOutput() 278 | Call with a string to set the output of the task. See image below. 279 | 280 | #### setWarning() 281 | Call with a string or Error instance to put the task in a warning state. 282 | 283 | #### setError() 284 | Call with a string or Error instance to put the task in an error state. Tasks automatically go into an error state when it catches an error in the task. 285 | 286 | 287 | 288 | 289 | ### task.group(createTaskFunctions, options) 290 | Returns a Promise that resolves with object: 291 | ```ts 292 | // The results from the taskFunctions 293 | type TaskGroupAPI = { 294 | // Result from taskFunction 295 | result: unknown 296 | 297 | // State of the task 298 | state: 'error' | 'warning' | 'success' 299 | 300 | // Invoke to clear the task result 301 | clear: () => void 302 | }[] & { 303 | 304 | // Invoke to clear ALL results 305 | clear: () => void 306 | } 307 | ``` 308 | 309 | #### createTaskFunctions 310 | Type: `(task) => Task[]` 311 | 312 | Required: true 313 | 314 | A function that returns all the tasks you want to group in an array. 315 | 316 | #### options 317 | 318 | Directly passed into [`p-map`](https://github.com/sindresorhus/p-map). 319 | 320 | ##### concurrency 321 | Type: `number` (Integer) 322 | 323 | Default: `1` 324 | 325 | Number of tasks to run at a time. 326 | 327 | ##### stopOnError 328 | Type: `boolean` 329 | 330 | Default: `true` 331 | 332 | 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. 333 | 334 | ## FAQ 335 | 336 | ### What does "Tasuku" mean? 337 | _Tasuku_ or タスク is the phonetic Japanese pronounciation of the word "task". 338 | 339 | 340 | ### Why did you make this? 341 | 342 | For writing scripts or CLI tools. _Tasuku_ is a great way to convey the state of the tasks that are running in your script without being imposing about the way you write your code. 343 | 344 | Major shoutout to [listr](https://github.com/SamVerschueren/listr) + [listr2](https://github.com/cenk1cenk2/listr2) for being the motivation and visual inspiration for _Tasuku_, and for being my go-to task runner for a long time. I made _Tasuku_ because I eventually found that they were too structured and declarative for my needs. 345 | 346 | Big thanks to [ink](https://github.com/vadimdemedes/ink) for doing all the heavy lifting for rendering interfaces in the terminal. Implementing a dynamic task list that doesn't interfere with `console.logs()` wouldn't have been so easy without it. 347 | 348 | ### Doesn't the usage of nested `task` functions violate ESLint's [no-shadow](https://eslint.org/docs/rules/no-shadow)? 349 | Yes, but it should be fine as you don't need access to other `task` functions aside from the immediate one. 350 | 351 | Put `task` in the allow list: 352 | - `"no-shadow": ["error", { "allow": ["task"] }]` 353 | - `"@typescript-eslint/no-shadow": ["error", { "allow": ["task"] }]` 354 | 355 | 356 | ## Sponsors 357 |

358 | 359 | 360 | 361 |

362 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tasuku", 3 | "version": "0.0.0-semantic-release", 4 | "description": "タスク — The minimal task runner", 5 | "keywords": [ 6 | "simple", 7 | "minimal", 8 | "task", 9 | "runner", 10 | "cli" 11 | ], 12 | "license": "MIT", 13 | "repository": "privatenumber/tasuku", 14 | "funding": "https://github.com/privatenumber/tasuku?sponsor=1", 15 | "author": { 16 | "name": "Hiroki Osame", 17 | "email": "hiroki.osame@gmail.com" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "type": "module", 23 | "main": "./dist/index.cjs", 24 | "module": "./dist/index.mjs", 25 | "types": "./dist/index.d.cts", 26 | "exports": { 27 | "require": { 28 | "types": "./dist/index.d.cts", 29 | "default": "./dist/index.cjs" 30 | }, 31 | "import": { 32 | "types": "./dist/index.d.mts", 33 | "default": "./dist/index.mjs" 34 | } 35 | }, 36 | "imports": { 37 | "#tasuku": { 38 | "types": "./src/index.ts", 39 | "development": "./src/index.ts", 40 | "default": "./dist/index.mjs" 41 | } 42 | }, 43 | "packageManager": "pnpm@9.15.4", 44 | "scripts": { 45 | "lint": "lintroll --cache .", 46 | "test": "tsx tests/tasuku.spec.ts", 47 | "test:tsd": "tsd", 48 | "type-check": "tsc", 49 | "build": "pkgroll --env.NODE_ENV=production --env.DEV=false --minify", 50 | "prepack": "clean-pkg-json" 51 | }, 52 | "dependencies": { 53 | "yoga-layout-prebuilt": "1.10.0" 54 | }, 55 | "devDependencies": { 56 | "@types/node": "^18.11.18", 57 | "@types/react": "^18.0.27", 58 | "clean-pkg-json": "^1.2.0", 59 | "ink": "github:privatenumber/ink#built/treeshake-lodash", 60 | "ink-task-list": "^2.0.0", 61 | "lintroll": "^1.15.0", 62 | "manten": "^1.3.0", 63 | "p-map": "^5.3.0", 64 | "pkgroll": "^2.8.2", 65 | "react": "^17.0.2", 66 | "tsd": "^0.31.2", 67 | "tsx": "^4.19.2", 68 | "typescript": "^5.7.3", 69 | "valtio": "^1.2.11" 70 | }, 71 | "tsd": { 72 | "directory": "tests" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/CreateApp.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'ink'; 2 | import React from 'react'; 3 | import type { TaskList } from '../types.js'; 4 | import TaskListApp from './TaskListApp.js'; 5 | 6 | export const createApp = (taskList: TaskList) => { 7 | const inkApp = render(); 8 | 9 | return { 10 | remove: () => { 11 | inkApp.rerender(null); 12 | inkApp.unmount(); 13 | inkApp.clear(); 14 | inkApp.cleanup(); 15 | }, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/TaskListApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC } from 'react'; 2 | import { TaskList } from 'ink-task-list'; 3 | import { useSnapshot } from 'valtio'; 4 | import type { TaskObject } from '../types.js'; 5 | import TaskListItem from './TaskListItem.js'; 6 | 7 | const TaskListApp: FC<{ 8 | taskList: TaskObject[]; 9 | }> = ({ 10 | taskList, 11 | }) => { 12 | const state = useSnapshot(taskList); 13 | 14 | return ( 15 | 16 | { 17 | state.map((task, index) => ( 18 | 22 | )) 23 | } 24 | 25 | ); 26 | }; 27 | 28 | export default TaskListApp; 29 | -------------------------------------------------------------------------------- /src/components/TaskListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { type FC } from 'react'; 2 | import { Task } from 'ink-task-list'; 3 | import type { TaskObject } from '../types.js'; 4 | 5 | type DeepReadonly = { readonly [K in keyof T]: DeepReadonly }; 6 | 7 | // From: https://github.com/sindresorhus/cli-spinners/blob/00de8fbeee16fa49502fa4f687449f70f2c8ca2c/spinners.json#L2 8 | const spinner = { 9 | interval: 80, 10 | frames: [ 11 | '⠋', 12 | '⠙', 13 | '⠹', 14 | '⠸', 15 | '⠼', 16 | '⠴', 17 | '⠦', 18 | '⠧', 19 | '⠇', 20 | '⠏', 21 | ], 22 | }; 23 | 24 | const TaskListItem: FC<{ 25 | task: DeepReadonly; 26 | }> = ({ 27 | task, 28 | }) => { 29 | const childTasks = ( 30 | task.children.length > 0 31 | ? task.children.map((childTask, index) => ( 32 | 36 | )) 37 | : [] 38 | ); 39 | 40 | return ( 41 | 0} 48 | > 49 | {childTasks} 50 | 51 | ); 52 | }; 53 | 54 | export default TaskListItem; 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { proxy } from 'valtio'; 2 | import pMap from 'p-map'; 3 | import { arrayAdd, arrayRemove } from './utils.js'; 4 | import { createApp } from './components/CreateApp.jsx'; 5 | import { 6 | type TaskList, 7 | type TaskObject, 8 | type Task, 9 | type TaskAPI, 10 | type TaskInnerAPI, 11 | type TaskGroupAPI, 12 | type TaskFunction, 13 | type RegisteredTask, runSymbol, 14 | } from './types.js'; 15 | 16 | const createTaskInnerApi = (taskState: TaskObject) => { 17 | const api: TaskInnerAPI = { 18 | task: createTaskFunction(taskState.children), 19 | setTitle(title) { 20 | taskState.title = title; 21 | }, 22 | setStatus(status) { 23 | taskState.status = status; 24 | }, 25 | setOutput(output) { 26 | taskState.output = ( 27 | typeof output === 'string' 28 | ? output 29 | : ( 30 | 'message' in output 31 | ? output.message 32 | : '' 33 | ) 34 | ); 35 | }, 36 | setWarning(warning) { 37 | taskState.state = 'warning'; 38 | 39 | if (warning !== undefined) { 40 | api.setOutput(warning); 41 | } 42 | }, 43 | setError(error) { 44 | taskState.state = 'error'; 45 | 46 | if (error !== undefined) { 47 | api.setOutput(error); 48 | } 49 | }, 50 | }; 51 | return api; 52 | }; 53 | 54 | let app: ReturnType | undefined; 55 | 56 | const registerTask = ( 57 | taskList: TaskList, 58 | taskTitle: string, 59 | taskFunction: TaskFunction, 60 | ): RegisteredTask => { 61 | if (!app) { 62 | app = createApp(taskList); 63 | taskList.isRoot = true; 64 | } 65 | 66 | const task = arrayAdd(taskList, { 67 | title: taskTitle, 68 | state: 'pending', 69 | children: [], 70 | }); 71 | 72 | return { 73 | task, 74 | [runSymbol]: async () => { 75 | const api = createTaskInnerApi(task); 76 | 77 | task.state = 'loading'; 78 | 79 | let taskResult; 80 | try { 81 | taskResult = await taskFunction(api); 82 | } catch (error) { 83 | api.setError(error as Error); 84 | throw error; 85 | } 86 | 87 | if (task.state === 'loading') { 88 | task.state = 'success'; 89 | } 90 | 91 | return taskResult; 92 | }, 93 | clear: () => { 94 | arrayRemove(taskList, task); 95 | 96 | if (taskList.isRoot && taskList.length === 0) { 97 | app!.remove(); 98 | app = undefined; 99 | } 100 | }, 101 | }; 102 | }; 103 | 104 | function createTaskFunction( 105 | taskList: TaskList, 106 | ): Task { 107 | const task: Task = async ( 108 | title, 109 | taskFunction, 110 | ) => { 111 | const registeredTask = registerTask(taskList, title, taskFunction); 112 | const result = await registeredTask[runSymbol](); 113 | 114 | return { 115 | result, 116 | get state() { 117 | return registeredTask.task.state; 118 | }, 119 | clear: registeredTask.clear, 120 | }; 121 | }; 122 | 123 | task.group = async ( 124 | createTasks, 125 | options, 126 | ) => { 127 | const tasksQueue = createTasks(( 128 | title, 129 | taskFunction, 130 | ) => registerTask( 131 | taskList, 132 | title, 133 | taskFunction, 134 | )); 135 | 136 | const results = (await pMap( 137 | tasksQueue, 138 | async taskApi => ({ 139 | result: await taskApi[runSymbol](), 140 | get state() { 141 | return taskApi.task.state; 142 | }, 143 | clear: taskApi.clear, 144 | }), 145 | { 146 | concurrency: 1, 147 | ...options, 148 | }, 149 | )) as any; // eslint-disable-line @typescript-eslint/no-explicit-any -- Temporary fix 150 | 151 | return Object.assign(results, { 152 | clear: () => { 153 | for (const taskApi of tasksQueue) { 154 | taskApi.clear(); 155 | } 156 | }, 157 | }); 158 | }; 159 | 160 | return task; 161 | } 162 | 163 | const rootTaskList = proxy([]); 164 | 165 | export default createTaskFunction(rootTaskList); 166 | export type { 167 | Task, 168 | TaskAPI, 169 | TaskInnerAPI, 170 | TaskFunction, 171 | TaskGroupAPI, 172 | }; 173 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'p-map'; 2 | 3 | type State = 'pending' | 'loading' | 'error' | 'warning' | 'success'; 4 | 5 | export type TaskObject = { 6 | title: string; 7 | state: State; 8 | children: TaskObject[]; 9 | status?: string; 10 | output?: string; 11 | }; 12 | 13 | export type TaskList = TaskObject[] & { 14 | isRoot?: boolean; 15 | }; 16 | 17 | export type TaskInnerAPI = { 18 | task: Task; 19 | setTitle(title: string): void; 20 | setStatus(status?: string): void; 21 | setWarning(warning?: Error | string): void; 22 | setError(error?: Error | string): void; 23 | setOutput(output: string | { message: string }): void; 24 | }; 25 | 26 | export type TaskFunction = (innerApi: TaskInnerAPI) => Promise; 27 | 28 | export const runSymbol: unique symbol = Symbol('run'); 29 | 30 | export type RegisteredTask = { 31 | [runSymbol]: () => Promise; // ReturnType>; 32 | task: TaskObject; 33 | clear: () => void; 34 | }; 35 | 36 | export type TaskAPI = { 37 | result: Result; 38 | state: State; 39 | clear: () => void; 40 | }; 41 | 42 | export type Task = ( 43 | ( 44 | 45 | /** 46 | * The task title 47 | */ 48 | title: string, 49 | 50 | /** 51 | * The task function 52 | */ 53 | taskFunction: TaskFunction 54 | ) => Promise> 55 | ) & { group: TaskGroup }; 56 | 57 | type TaskGroupResults< 58 | RegisteredTasks extends RegisteredTask[] 59 | > = { 60 | [Key in keyof RegisteredTasks]: ( 61 | RegisteredTasks[Key] extends RegisteredTask 62 | ? TaskAPI 63 | : unknown 64 | ); 65 | }; 66 | 67 | export type TaskGroupAPI = Results & { 68 | clear(): void; 69 | }; 70 | 71 | export type CreateTask = ( 72 | 73 | /** 74 | * The task title 75 | */ 76 | title: string, 77 | 78 | /** 79 | * The task function 80 | */ 81 | taskFunction: TaskFunction, 82 | ) => RegisteredTask; 83 | 84 | type TaskGroup = < 85 | RegisteredTasks extends RegisteredTask[] 86 | >( 87 | createTasks: (taskCreator: CreateTask) => readonly [...RegisteredTasks], 88 | options?: Options 89 | ) => Promise>>; 90 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These utilities are useful because Valtio 3 | * makes the elements reactive after adding to 4 | * the array. 5 | */ 6 | 7 | export const arrayAdd = ( 8 | array: T[], 9 | element: T, 10 | ) => { 11 | const index = array.push(element) - 1; 12 | return array[index]; 13 | }; 14 | 15 | export const arrayRemove = ( 16 | array: T[], 17 | element: T, 18 | ) => { 19 | const index = array.indexOf(element); 20 | if (index !== -1) { 21 | array.splice(index, 1); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /tests/tasuku.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, expect } from 'manten'; 2 | import task from '#tasuku'; 3 | 4 | const sleep = (ms: number): Promise => new Promise((resolve) => { 5 | setTimeout(resolve, ms); 6 | }); 7 | 8 | test('task - return number', async () => { 9 | const { result } = await task('Some task', async () => 1 + 1); 10 | expect(result).toBe(2); 11 | }); 12 | 13 | test('task - return string', async () => { 14 | const { result } = await task('Some task', async () => 'some string'); 15 | expect(result).toBe('some string'); 16 | }); 17 | 18 | test('task return Promise', async () => { 19 | const { result } = await task( 20 | 'Some task', 21 | async () => await new Promise((resolve) => { 22 | resolve(123); 23 | }), 24 | ); 25 | expect(result).toBe(123); 26 | }); 27 | 28 | test('nested tasks', async () => { 29 | const someTask = await task('Some task', async ({ task }) => { 30 | const nestedTask = await task('nested task', async () => 'nested works'); 31 | expect(nestedTask.result).toBe('nested works'); 32 | 33 | return 1; 34 | }); 35 | 36 | expect(someTask.result).toBe(1); 37 | }); 38 | 39 | describe('group tasks', ({ test }) => { 40 | test('task results', async () => { 41 | const groupTasks = await task.group(task => [ 42 | task('number', async () => 123), 43 | task('string', async () => 'hello'), 44 | task('boolean', async () => false), 45 | ]); 46 | 47 | expect<{ result: number }>(groupTasks[0]).toMatchObject({ 48 | state: 'success', 49 | result: 123, 50 | }); 51 | expect<{ result: string }>(groupTasks[1]).toMatchObject({ 52 | state: 'success', 53 | result: 'hello', 54 | }); 55 | expect<{ result: boolean }>(groupTasks[2]).toMatchObject({ 56 | state: 'success', 57 | result: false, 58 | }); 59 | }); 60 | 61 | test('concurrency - series', async () => { 62 | const startTime = Date.now(); 63 | const groupTasks = await task.group(task => [ 64 | task('one', async () => { 65 | await sleep(100); 66 | return 1; 67 | }), 68 | task('two', async () => { 69 | await sleep(100); 70 | return 2; 71 | }), 72 | task('three', async () => { 73 | await sleep(100); 74 | return 3; 75 | }), 76 | ]); 77 | 78 | const elapsed = Date.now() - startTime; 79 | 80 | expect(elapsed > 300 && elapsed < 400).toBe(true); 81 | 82 | expect<{ result: number }>(groupTasks[0]).toMatchObject({ 83 | state: 'success', 84 | result: 1, 85 | }); 86 | expect<{ result: number }>(groupTasks[1]).toMatchObject({ 87 | state: 'success', 88 | result: 2, 89 | }); 90 | expect<{ result: number }>(groupTasks[2]).toMatchObject({ 91 | state: 'success', 92 | result: 3, 93 | }); 94 | }); 95 | 96 | test('concurrency - parallel', async () => { 97 | const startTime = Date.now(); 98 | const groupTasks = await task.group(task => [ 99 | task('one', async () => { 100 | await sleep(100); 101 | return 1; 102 | }), 103 | task('two', async () => { 104 | await sleep(100); 105 | return 2; 106 | }), 107 | task('three', async () => { 108 | await sleep(100); 109 | return 3; 110 | }), 111 | ], { concurrency: Number.POSITIVE_INFINITY }); 112 | 113 | const elapsed = Date.now() - startTime; 114 | 115 | expect(elapsed > 100 && elapsed < 300).toBe(true); 116 | 117 | expect<{ result: number }>(groupTasks[0]).toMatchObject({ 118 | state: 'success', 119 | result: 1, 120 | }); 121 | expect<{ result: number }>(groupTasks[1]).toMatchObject({ 122 | state: 'success', 123 | result: 2, 124 | }); 125 | expect<{ result: number }>(groupTasks[2]).toMatchObject({ 126 | state: 'success', 127 | result: 3, 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /tests/tasuku.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | import task from '#tasuku'; 3 | 4 | const booleanResult = await task('title', async () => false); 5 | expectType(booleanResult.result); 6 | 7 | const numberResult = await task('title', async () => 123); 8 | expectType(numberResult.result); 9 | 10 | const stringResult = await task('title', async () => 'string'); 11 | expectType(stringResult.result); 12 | 13 | const groupApi = await task.group(task => [ 14 | task('title', async () => false), 15 | task('title', async () => 123), 16 | task('title', async () => 'string'), 17 | ]); 18 | 19 | expectType(groupApi[0].result); 20 | expectType(groupApi[1].result); 21 | expectType(groupApi[2].result); 22 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | import task from '../dist/index.mjs'; 2 | 3 | task(`hi ${process.version}`, () => {}); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | 4 | // Node 12 5 | "target": "ES2019", 6 | "jsx": "react", 7 | "module": "Preserve", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "isolatedModules": true, 11 | "skipLibCheck": true, 12 | "noEmit": true, 13 | }, 14 | } 15 | --------------------------------------------------------------------------------