@oliveryasuna/ts-task
v1.2.0
Published
A task runner configured entirely in TypeScript.
Maintainers
Readme
ts-task
A task runner you configure in TypeScript, where the types actually reach across task boundaries.
Most task runners hand you YAML, or JSON, or a pile of untyped JavaScript. That's fine until one task depends on another and you want to pass something between them. Suddenly you're threading strings through a config the type checker knows nothing about, and a typo in a dependency name or a task's option only shows up when you run the thing. ts-task puts the whole build graph in a normal .ts file, so a task's dependencies, its inputs, and its CLI options are all just types. Wire a dependency up wrong, forget to bind an input, name two options the same thing, and it's a red squiggle in your editor rather than a surprise at the terminal.
[!NOTE] In case you're allergic to AI, rest easy knowing this project was handwritten (I wrote this in a day, so let me know if there are any issues). Documentation was AI-assisted.
Install
bun add --global @oliveryasuna/ts-task
# or pnpm
pnpm add --global @oliveryasuna/ts-task
# or npm
npm install --global @oliveryasuna/ts-task
# or yarn
yarn add --global @oliveryasuna/ts-taskThe runner itself ships as an ESM binary and runs on Node 22+ or Bun. Your config file is TypeScript and gets transpiled on the fly, so there's no build step between editing tasks.config.ts and running it.
The idea
You put your tasks in a tasks.config.ts at the root of your project and default-export a config. Each task has a name, an optional description, and a run function that does the work:
import {defineConfig, task} from '@oliveryasuna/ts-task';
const lint = task({
name: 'lint',
description: 'Run ESLint',
run: async(ctx) => {
const result = await ctx.exec('eslint', ['.']);
if(result.code !== 0) {
throw new Error(`eslint exited ${result.code}`);
}
ctx.log.info('lint clean');
}
});
export default defineConfig({
tasks: [lint],
defaultTask: 'lint'
});Then run it:
tstask lint # or just `tstask`, since lint is the default task
# aliases: tt, ts-taskctx is where the work happens. It carries exec for running commands, log for scoped output, cwd, env, an AbortSignal, and (once you add them) the resolved deps, input, and options. One thing worth knowing up front: ctx.exec resolves on a non-zero exit instead of throwing, and it buffers output rather than streaming it. That's so a task can inspect the exit code and decide for itself, but it means you have to check result.code yourself, like above. In practice you'll write a little sh helper once and reuse it.
Dependencies
List other tasks in deps and the runner makes sure they finish first. A task with no input goes in the array directly:
const build = task({
name: 'build',
deps: [lint],
run: async(ctx) => {
await ctx.exec('tsdown');
ctx.log.info('bundled');
}
});The dependency graph is walked once, so a task that shows up as a dependency of three different tasks still runs exactly once, and its result is shared. Independent tasks run concurrently, capped at one less than your core count by default (tune it with -j). A cycle is caught before anything runs and reported with the path that closed the loop.
A dependency's output lands in ctx.deps, keyed by the task's name:
const compile = task({
name: 'compile',
run: async() => ({bytes: 4096})
});
const report = task({
name: 'report',
deps: [compile],
run: (ctx) => {
ctx.log.info(`compiled ${ctx.deps.compile.bytes} bytes`);
}
});ctx.deps.compile is fully typed as {bytes: number}, inferred straight from compile's run. Nothing is stringly-typed here.
Inputs and options
These are the two ways to feed a task, and the difference between them is the whole point, so it's worth being precise.
An option comes from the command line and is global to the run. You declare it with opt, and it becomes a real CLI flag:
const lint = task({
name: 'lint',
options: {
fix: opt.boolean().describe('Apply autofixes').alias('f'),
'max-warnings': opt.number().default(0).describe('Warnings tolerated before failing')
},
run: async(ctx) => {
const args = ctx.options.fix ? ['--fix'] : [];
// ctx.options.fix is boolean, ctx.options['max-warnings'] is number
}
});An input comes from the dependent task, in TypeScript, at the point the dependency is wired up. It never touches the command line and it's never parsed from a string. You declare its shape with type, and a task that has one is bound with .with(...):
import {defineConfig, task, type as input, opt} from '@oliveryasuna/ts-task';
const typecheck = task({
name: 'typecheck',
input: input<{project: string}>(),
run: async(ctx) => {
await ctx.exec('tsc', ['--noEmit', '--project', ctx.input.project]);
}
});
const build = task({
name: 'build',
deps: [typecheck.with({project: 'tsconfig.json'})],
run: async(ctx) => { /* ... */ }
});So: whoever's at the terminal picks options; the config author picks inputs. A task that declares an input can't be a command-line entry point, because there's no .with() on a command line and its ctx.input would be empty. The type checker won't let you list it in tasks for that reason, and it won't let you list it as a dependency without binding it either. typecheck above stays perfectly reachable through build, it just isn't something you type at the shell.
Namespaces
Task IDs can be qualified with :, like build:compile, which keeps related tasks grouped without long unwieldy names. The key a dependency shows up under in ctx.deps defaults to the last segment, so namespacing never forces you into bracket access:
import {namespace} from '@oliveryasuna/ts-task';
const build = namespace('build');
const compile = build.task({name: 'compile', run: /* ... */});
// its id is 'build:compile', and a dependent reads ctx.deps.compileIf two dependencies happen to share a last segment, rename one edge with .as('somethingElse'). That collision is caught at compile time too, with a message telling you which key clashed.
Caching
The runner doesn't ship a cache. What it ships is the two seams a cache needs, and you decide how much of it you want.
A CacheStore at the config level is where entries live. A cache policy, attached to a task with .cached(...), decides whether that task participates, what its cache key is, and how its output is serialized. A task with no policy always runs; a config with no store ignores every policy. So you can opt in one task at a time.
const build = task({
name: 'build',
run: async(ctx) => ({hash: 'abc123'})
}).cached({
key: (ctx) => JSON.stringify([ctx.taskId, sourceFileHash()]),
validate: (entry) => stillFresh(entry) // optional, runs on a hit
});The one thing to keep in mind: the runner never derives any part of your key for you. In particular, a dependency's output is not folded in automatically. If a task's result depends on what compile produced, you have to put the relevant piece of ctx.deps.compile into the key yourself. This is deliberate, since most dependency outputs are irrelevant to a given consumer and hashing all of them wholesale would defeat the cache, but it does mean an incomplete key is silently wrong rather than merely slow. Put everything that can change the output into the key: the input, the options run actually reads, the relevant parts of ctx.deps, and any out-of-band state like source hashes or tool versions.
Plugins
A plugin bundles up things worth reusing across projects: a set of tasks, a way to report progress, a cache backend, or a rewrite of the task graph. Since the config is just TypeScript, a plugin is just an object. There's no registration ceremony and nothing to wire into a manifest; you drop it in the plugins array and its contributions get folded into the run.
import {defineConfig, task} from '@oliveryasuna/ts-task';
const eslintPlugin = {
name: 'eslint',
tasks: [
task({name: 'lint', run: async(ctx) => { /* ... */ }})
]
};
export default defineConfig({
plugins: [eslintPlugin],
tasks: [/* your own tasks */],
defaultTask: 'lint'
});A plugin has a name and any of the following, all optional.
tasks are merged into the graph alongside your own, and their options show up as CLI flags exactly like a hand-written task's.
reporter observes execution. Every hook is optional and runs alongside the built-in reporter that prints the usual [task] done in Nms lines, so you can add JSON output, CI annotations, or timing without replacing anything:
const timing = {
name: 'timing',
reporter: {
onTaskEnd: (e) => { console.error(`${e.taskId} took ${e.durationMs}ms`); }
}
};The hooks are onRunStart, onTaskStart, onTaskEnd, onTaskError, onCacheHit, onCacheMiss, and onRunEnd. A reporter only observes, so if one throws, the run carries on regardless.
cache provides a default CacheStore, the same seam from the Caching section. An explicit cache on the config wins; otherwise the first plugin that offers one is used.
transform rewrites the merged task list. It runs after every plugin's tasks are merged in, so you can add, remove, or wrap tasks:
import {wrapRun} from '@oliveryasuna/ts-task';
const wrapTiming = {
name: 'wrap-timing',
transform: (tasks) => tasks.map((t) => wrapRun(t, (run) => async(ctx) => {
const start = Date.now();
const out = await run(ctx);
ctx.log.info(`took ${Date.now() - start}ms`);
return out;
}))
};wrapRun rebuilds a task with its run wrapped, keeping its dependencies, options, and cache policy intact. Two things to know about it. It wraps a task's own run, so it takes effect when that task is an entry point or when you build new dependency edges from the returned task, but it does not retroactively rewrap tasks that already depend on the original. And transform works on the erased task type, without the per-task generics you get from task(...).
That last point is the one place the type safety steps back. The compile-time checks (duplicate ids, an input-declaring task used as an entry point) only cover the tasks you write literally in tasks. Anything a plugin contributes or a transform produces is checked when the config resolves instead, so you still get the same error, just at startup rather than in your editor.
Running
tstask # run the default task
tstask build verify # run several
tstask --list # list every task, its options, and which one is default
tstask build --watch # re-run when files under the config's directory change
tstask build --dry-run # print the plan without running anythingThe flags you always have:
-c, --config <path>: point at a config file instead of discovering one--cwd <dir>: working directory to resolve from-l, --list: list available tasks-w, --watch: re-run on source changes (debounced, ignoresnode_modules,.git, anddist)--dry-run: print the plan without running--no-cache: ignore the configured cache store for this run-j, --concurrency <n>: cap how many tasks run at once--verbose: debug logging-v, --version
Every option your tasks declare shows up as a flag alongside these. Ctrl-C aborts cleanly: the signal is threaded into every run and every exec, so in-flight commands get told to stop rather than being orphaned.
The config file is discovered by walking for tasks.config.ts (or .mts, .js, .mjs) from your working directory. It's imported through jiti, so a TypeScript config just works with no separate compile step, and path aliases from your tsconfig.json are respected.
Contributing
Fully AI-generated pull requests are not accepted. You can use AI, but it should be verified and cleaned up by a human. Only Opus 4.6+ (high-effort) and Codex 5.4+ (extra high) are accepted models. Preferably created with Opus and verified by Codex. This blurb is adapted from Ink.
I think that's a reasonable ask for a small library like this one. If you think it's too strict, open an issue and tell me why.
License
MIT © Oliver Yasuna
