Beautiful CLI task management with dynamic subtask injection
Installation Β· Quick Start Β· API Β· Examples
| Feature | Description | |
|---|---|---|
| π― | Dynamic Subtasks | Add and nest subtasks at runtime |
| π | Lifecycle Hooks | setup β task β afterEach β finally |
| β¨ | Ora-like API | Familiar succeed(), fail(), warn(), info() methods |
| π‘ | Animated Spinners | Beautiful tree-structured output with colors |
| π | Error Handling | Built-in retry, skip, and rollback support |
| π€« | Console Safe | Intercepts logs without breaking the display |
| π§ͺ | Test Friendly | Silent renderer for CI/testing |
npm install @shoru/listrxRequires Node.js 18+
import { createTask } from '@shoru/listrx';
const task = createTask({ title: 'π Deploy' });
task.add({ title: 'Build', task: async () => await build() });
task.add({ title: 'Test', task: async () => await test() });
task.add({ title: 'Upload', task: async () => await upload() });
await task.complete();β π Deploy
βββ β Build
βββ β Test
βββ β Upload
import { createTask, loader } from '@shoru/listrx';Simple ora-like spinner for quick operations.
const spinner = loader('Working...').start();
spinner.text = 'Still working...';
spinner.color = 'yellow';
spinner.succeed('Complete'); // β | Also: fail(), warn(), info(), stop()Full-featured task with subtask support and lifecycle hooks.
const task = createTask({
title: 'My Task', // Required
// π Lifecycle hooks
setup: async (ctx, task) => {}, // Runs once, first
task: async (ctx, task, type) => {}, // Runs after setup (type: 'initial' | 'auto' | 'retry')
afterEach: async (ctx, completedSubtask, mainTask) => {}, // After each subtask
finally: async (ctx, task) => {}, // Runs last, once
// βοΈ Execution options
options: { concurrent: false, exitOnError: true },
// β±οΈ Auto behaviors (for watch mode / streaming)
autoExecute: 500, // Run task X ms after last add() - task stays open
autoComplete: 2000, // Complete X ms after idle - task closes
// π Error handling
retry: { tries: 3, delay: 1000 },
skip: (ctx) => false,
rollback: async (ctx, task) => {},
// π¨ Display
showTimer: false,
spinnerColor: 'cyan',
rendererOptions: { renderer: 'default' } // 'default' | 'simple' | 'silent'
});setup (once) β task β subtasks β finally
β β
afterEach afterEach (per subtask)
| Hook | Runs | Purpose |
|---|---|---|
setup |
Once | Initialize context, setup resources |
task |
Once (or per autoExecute) |
Main work before subtasks |
afterEach |
Per subtask | Track progress, logging |
finally |
Once | Cleanup, final message |
The task function receives a third parameter type indicating how it's being executed:
task: async (ctx, task, type) => {
// type: 'initial' | 'auto' | 'retry'
}| Type | When | Description |
|---|---|---|
'initial' |
First execution | Regular call on first attempt |
'auto' |
autoExecute trigger |
Called when autoExecute timer fires |
'retry' |
Retry attempts | Called on retry after failure |
const task = createTask({
title: 'Smart Task',
retry: { tries: 3, delay: 1000 },
task: async (ctx, task, type) => {
if (type === 'retry') {
task.output = 'Retrying with fallback strategy...';
return fallbackMethod();
}
return primaryMethod();
}
});For watch mode or streaming scenarios where subtasks arrive over time:
| Property | Behavior |
|---|---|
autoExecute |
Triggers task (setup only once) after X ms of no new subtasks. Task stays open. |
autoComplete |
Triggers finally and closes task after X ms of complete idle. |
// Add subtasks (single or batch)
const sub = task.add({ title: 'Step 1', task: async () => {} });
const [a, b] = task.add([{ title: 'A' }, { title: 'B' }]);
// Nest subtasks
const parent = task.add({ title: 'Parent' });
parent.add({ title: 'Child' });
// Control
await task.complete(); // Finish task (runs finally)
task.forceShutdown('Reason'); // Abort immediately
// Subscribe to events
task.state$((state) => {}); // 'pending' | 'processing' | 'completed' | 'failed'
task.subtasks$((subtask) => {}); // Called when subtask is addedInside a task function, control the subtask state:
task.add({
title: 'Check',
task: async (ctx, task, type) => {
task.title = 'Checking...'; // Update title
task.output = 'Step 1 of 3'; // Show status line
task.spinnerColor = 'yellow';
// Handle different execution types
if (type === 'retry') {
task.output = 'Retrying...';
}
// Final states (ora-like)
task.succeed('All good'); // β green
task.fail('Error'); // β red
task.warn('Warning'); // β yellow
task.info('Note'); // βΉ blue
}
});task.state // 'pending' | 'processing' | 'completed' | 'failed'
task.title // Task title
task.ctx // Shared context object
task.promise // Awaitable completion promise
task.subtaskCount // Total subtask count
task.isPending / isProcessing / isCompleted / isFailedtype SpinnerColor =
| 'black' | 'red' | 'green' | 'yellow' | 'blue'
| 'magenta' | 'cyan' | 'white' | 'gray' | 'grey'
| 'redBright' | 'greenBright' | 'yellowBright'
| 'blueBright' | 'magentaBright' | 'cyanBright' | 'whiteBright';const task = createTask({ title: 'ποΈ Build' });
const frontend = task.add({ title: 'Frontend' });
frontend.add({ title: 'TypeScript', task: compileTs });
frontend.add({ title: 'CSS', task: bundleCss });
const backend = task.add({ title: 'Backend' });
backend.add({ title: 'Compile', task: compile });
await task.complete();β ποΈ Build
βββ β Frontend
β βββ β TypeScript
β βββ β CSS
βββ β Backend
βββ β Compile
const task = createTask({
title: 'Pipeline',
setup: async (ctx) => {
ctx.startTime = Date.now();
ctx.completed = 0;
},
task: async (ctx, task, type) => {
task.output = 'Loading config...';
ctx.config = await loadConfig();
},
afterEach: async (ctx, completedSubtask, mainTask) => {
ctx.completed++;
mainTask.output = `Progress: ${ctx.completed}/${mainTask.childCount}`;
},
finally: async (ctx, task) => {
const duration = Date.now() - ctx.startTime;
task.succeed(`Done in ${duration}ms`);
}
});
task.add({ title: 'Fetch', task: fetchData });
task.add({ title: 'Process', task: processData });
await task.complete();Use autoExecute and autoComplete for file watchers or streaming data:
const task = createTask({
title: 'File Watcher',
autoExecute: 500, // Batch files, run task 500ms after last change
autoComplete: 5000, // Finish 5s after idle
setup: async (ctx) => {
ctx.batches = 0; // Runs once
},
task: async (ctx, task, type) => {
ctx.batches++; // Runs each autoExecute trigger
task.output = `Processing batch #${ctx.batches}`;
// type will be 'initial' for first batch, 'auto' for subsequent
if (type === 'auto') {
task.output = `Auto-processing batch #${ctx.batches}`;
}
},
finally: async (ctx, task) => {
task.succeed(`Processed ${ctx.batches} batches`);
}
});
watcher.on('change', (file) => {
task.add({ title: file, task: () => compile(file) });
});
await task.promise;
// Timeline example:
// 0-200ms - files added
// 700ms - autoExecute β setup + task (type: 'initial', batch #1)
// 1000ms - more files added
// 1500ms - autoExecute β task only (type: 'auto', batch #2)
// 6500ms - autoComplete β finally, task closesconst task = createTask({
title: 'Process Images',
options: { concurrent: true }
});
images.forEach(img => {
task.add({ title: img.name, task: () => processImage(img) });
});
await task.complete();task.add({
title: 'Upload',
task: async (ctx, task, type) => {
if (type === 'retry') {
task.output = 'Retrying with exponential backoff...';
} else {
task.output = 'Uploading...';
}
await upload();
},
retry: { tries: 3, delay: 1000 }, // Retry on failure
skip: (ctx) => ctx.offline && 'No connection', // Skip with reason
rollback: async (ctx, task) => { // Cleanup on failure
await cleanup();
}
});const task = createTask({
title: 'API Call',
retry: { tries: 3, delay: 2000 },
task: async (ctx, task, type) => {
switch (type) {
case 'initial':
task.output = 'Attempting primary endpoint...';
return await callPrimaryAPI();
case 'retry':
task.output = 'Falling back to secondary endpoint...';
return await callSecondaryAPI();
case 'auto':
task.output = 'Auto-refresh triggered...';
return await refreshData();
}
}
});
await task.complete();const task = createTask({ title: 'Build', showTimer: true });
task.add({ title: 'Compile', task: compile });
await task.complete();β Build [2.3s]
βββ β Compile [2.1s]
const task = createTask({
title: 'Test',
rendererOptions: { renderer: 'silent' }
});
task.add({ title: 'Step', task: async () => results.push(1) });
await task.complete();
expect(task.state).toBe('completed');| Renderer | Output | Use Case |
|---|---|---|
'default' |
Animated spinners | Interactive terminals |
'simple' |
Plain text | CI/CD, logs |
'silent' |
None | Testing |
createTask({
rendererOptions: {
renderer: process.env.CI ? 'simple' : 'default'
}
});Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create feature branch
git checkout -b feature/amazing-feature - Commit changes
git commit -m 'Add amazing feature' - Push
git push origin feature/amazing-feature - Open Pull Request
This project is licensed under the MIT License β see the LICENSE file for details.
Made with β€οΈ for the Node.js CLI community