Task API
Everything do.ts can export, in one place. All of it comes from "@meslzy/outdo" (and the compose preset from "@meslzy/outdo/compose").
defineTasks(tasks, options?)
The single default export. Keys are task names; values are entries (below). Dep, step, finally and hook names are validated across entries at compile time, and env / args on an entry type its run context.
Entries
| entry | meaning |
|---|---|
"cmd args" | one Bun Shell command; args after -- are appended |
["cmd1", "cmd2"] | commands in order; passthrough args go on the last |
{ ...fields } | a task object |
task({ ... }) | a task object built ahead of time |
ns({ a, b }) | a namespace: tasks ns:a, ns:b |
Options
| option | type | meaning |
|---|---|---|
modes | readonly string[] | allowed --mode values; the first is the default. Types ctx.mode and protect |
members | Record<string, MemberDefaults> | defaults merged into every workspace member's task of that name — deps, inputs, outputs, watch, desc, group, requires, protect, confirm, readonly, destructive, … |
hooks | { [hook]: Command | HookDef } | git hooks. HookDef: { desc?, deps?, steps?, run?, restage? } |
export default defineTasks(
{ fix: "bunx biome check --write", tsc: "bunx tsc --noEmit" },
{
modes: ["dev", "prod"],
members: { tsc: { deps: ["^tsc"] } },
hooks: { "pre-commit": { steps: ["fix", "tsc"], restage: true } },
},
);ns(tasks)
Groups entries under a namespace: db: ns({ reset, backup }) defines db:reset and db:backup. Inside, deps/steps name siblings first, then top-level tasks. Typed like defineTasks (except ctx.mode, which is string there). Namespaces don't nest.
task(def)
Optional since defineTasks types env / args itself. Use it for a task defined outside the map — typically a producer for from(). Its run must be a function; use a plain entry for commands.
optional(name)
A dep entry for a task that may not exist — silently dropped when absent, a normal hard edge when present. See conditional and optional deps.
from(name, taskValue)
A typed dep entry: carries the producer's run() return type into the consumer's ctx.deps[name]. See task outputs as data.
Task definition fields
| field | type | notes |
|---|---|---|
desc | string | task list one-liner |
group | string | task list heading |
run | (ctx) => R | string | string[] | the body; omit for aggregation tasks. A function's awaited return value is the task's JSON data output. With no run/steps, a same-named package.json script runs |
deps | readonly DepEntry[] | names, ^name, pkg#name, { task, if?, optional? } objects, optional(), from() — names compile-time checked |
steps | readonly string[] | run one after another after deps; --from / --until |
finally | readonly string[] | always run afterwards, even on failure / Ctrl-C |
env | StandardSchemaV1 | zod / valibot / arktype schema, validated pre-run; types ctx.env |
args | Record<string, ArgSpec | StandardSchemaV1> | typed CLI flags; types ctx.args |
positional | readonly string[] | flag names fillable by bare words, in order |
envFiles | readonly string[] | extra env files (+ their .<mode> variants), relative to do.ts |
inputs | readonly string[] | cache fingerprint globs (supports ! negation) |
outputs | readonly string[] | must exist for a cache hit; restored from the cache when deleted |
watch | readonly string[] | watch-mode triggers (defaults to inputs) |
persistent | boolean | service semantics; incompatible with outputs, retry, timeoutMs, steps |
ready | { log | url | port, host?, timeoutMs? } | readiness probe; persistent only, exactly one probe kind |
retry | number | { attempts, delayMs?, backoff? } | opt-in retries; number = total attempts; backoff: "fixed" | "exponential" |
timeoutMs | number | per-attempt budget; cooperative abort → grace → kill |
cwd | string | working dir, relative to do.ts |
protect | true | Mode | readonly Mode[] | ask before running (in those modes) |
confirm | string | always ask this question first |
requires | { bin?, env?, file?, url?, port?, host?, hint? } | pre-checks right before the task starts |
readonly | boolean | safe unattended; allowed under OUTDO_READONLY=1 |
destructive | boolean | hard to undo; MCP destructiveHint |
ArgSpec: { type: "string" | "boolean", short?, default?, required?, choices?, suggest?, description? }.
Validation at load (exit 3): unknown fields; ready requires persistent; exactly one of log/url/port; host only with port; retry.attempts >= 1; positive timeoutMs; persistent tasks reject outputs/retry/timeoutMs/steps; positional names must be declared, non-boolean flags, and only the last may be an array; unknown dep/step/finally names (with a did-you-mean).
The context
Hover each field: $Bun Shell ($)Pre-bound to the task's cwd and resolved env, with node_modules/.bin on PATH. Interpolations are auto-escaped — injection-safe by default. · envschema output | Record<string, string | undefined>The validated output of your env schema (coercions applied), or the raw resolved env map when no schema is declared. Includes the task's envFiles for the active mode. · argsinferred from argsParsed per-task CLI flags with defaults applied. Schema flags give the schema output; choices narrow to a union; required/default narrow away undefined. · depsinferred from dep entriesData outputs of this task's dependencies — their run() return values. from() entries are fully typed; plain string entries are unknown. Replayed from the cache on hits. · reststring[]Raw arguments after --. Delivered only to tasks named on the command line, never to dependencies. · signalAbortSignalFires on Ctrl-C, fail-fast aborts, timeouts, and watch supersession. Respect it in long loops — ignoring it means a tree-kill after 5s. · logTaskLoggerinfo/warn/error/debug — prefixed with the task name, safe under concurrent multiplexed output. · cwdstringAbsolute working directory of this task. · rootstringAbsolute repo root: the workspace root, or the directory of the root do.ts. · namestringThe task's name (pkg#task for workspace members). · modeunion of your modesThe active --mode; the first declared mode by default, empty string when the project declares none. · ssh(target, options?) => RemoteShellA $-like tagged template that runs on a remote host over ssh: escaped values, label-prefixed streaming output, .cwd(), .nothrow(), .quiet().
run: async ({ $, env, args, deps, rest, signal, log, cwd, root, name, mode, ssh }) => {
log.info(`starting in ${mode}`);
const out = await $`git rev-parse --short HEAD`.text();
if (signal.aborted) {
return;
}
await $`echo building ${out.trim()}`.cwd(`${root}/apps/web`);
},ssh(target, { label?, args? }) returns a RemoteShell: await vps`cmd ${value}` resolves to { exitCode, stdout, stderr } and throws on a non-zero exit; .cwd(dir), .nothrow() and .quiet() return a configured copy. See Deploys over ssh.
Type-level behavior worth knowing
import { defineTasks } from "@meslzy/outdo";
import { z } from "zod";
export default defineTasks(
{
build: "bun run build",
broken: {
deps: ["biuld"], // compile error: Did you mean "build"?
},
typed: {
env: z.object({ PORT: z.coerce.number() }),
args: { fast: { type: "boolean" }, svc: { type: "string", choices: ["api", "web"], required: true } },
protect: "prod",
run: ({ env, args, mode }) => {
env.PORT; // number — schema output, not string
args.fast; // boolean — never undefined
args.svc; // "api" | "web"
mode; // "dev" | "prod"
// @ts-expect-error unknown env keys are compile errors too
env.MISSING;
},
},
// @ts-expect-error "staging" is not a declared mode
nope: { protect: "staging" },
},
{ modes: ["dev", "prod"] },
);- Exported functions:
defineTasks,ns,task,optional,from. Exported types includeTaskCtx,TaskDef,TaskEntry,Command,Shell(the type ofctx.$— no moreParameters<…>gymnastics),ArgSpec,ArgDecl,DepEntry,DepRef,QualifiedDep,ReadyProbe,RetrySpec,Requires,HookDef,GitHook,MemberDefaults,DefineOptions,Namespace,Ssh,SshOptions,RemoteShell,RemoteResult,RawEnv,StandardSchemaV1. - Inline typing of
env/argsneeds TypeScript 6 (5.9 leavesrequired/choicesflags asstring | undefined). - Task names can be any string —
"db:reset","deploy"— quotes required only when not a valid identifier. from()producers propagate return-type changes to every consumer automatically; conditional/optional entries widen the consumer's view toT | undefined.
@meslzy/outdo/compose
compose(options?, baseDir?) returns the Docker Compose task set — up, down, restart, rebuild, fresh, logs, ps, shell, exec — to spread into defineTasks. Also exported: runCompose(options, ctx, args) (run one docker compose command from your own task), servicesInFile(text), and the ComposeOptions / ComposeTasks types.