xtask
A task runner whose tasks are data.
Write an xtask.yaml, and run xtask <task>. It resolves the dependency
graph, runs each task once, in declared order, without a shell.
That is the whole of it, and the repository does not have to be a Dart one:
the engine starts programs, so [pytest, -q] is as ordinary a task as
[dart, test]. Later you may want to pin the engine's version in a
pubspec.yaml instead of installing it, or to write a task in Dart rather than
name a program — Using it from Dart is those two steps,
and neither is needed to start.
The point is not convenience. It is that a repository stops keeping the same
list twice. A Makefile names the commands, the CI workflow names them again,
and a third copy usually lives in a contributing guide — and the copies drift
the first time somebody is in a hurry. Here CI stops naming commands at all: a
job runs one task, and what that task is made of lives in one file.
Start here
Install the engine once. Your repository needs no pubspec.yaml and no Dart in
it at all — only the Dart SDK on the machine doing the installing:
dart install xtask
What lands on the PATH is a command called xtask.
Then write xtask.yaml at the repository root. This is a whole one:
version: 1
gates: [check]
tasks:
lint:
desc: check the style
gate: [check]
run: [ruff, check, .]
test:
desc: run the suite
gate: [check]
run: [pytest, -q]
Those two are a Python repository's tools; put in whatever your own repository already runs. Naming a program that is not installed is not a silent pass — the run stops and says which one:
error: task `lint`: `ruff` is not installed, or is not on PATH — nothing
runnable by that name in the 39 directories on PATH
And run it:
xtask check
That runs both, in the order they are written, and it is what a person types
before calling work done — and also the whole of the CI job, the same command,
because there is only one list and it is not in either of them. xtask --list
prints the tasks with their descriptions.
Two lines in that file are doing the work. gates: [check] at the top says
which groups this repository has; gate: [check] on a task says it is in one.
xtask check then runs the group, in the order the file writes it — a gate set
is not a task and needs none. Gate sets says why the whole
tool exists for those two lines.
Using it from Dart
Two steps, and a Dart repository is the only kind that can take them. Neither is needed to start, and a repository that never takes either is using the tool exactly as intended.
Depending on it instead
A Dart repository can depend on xtask rather than installing it, and then the
version is written down in pubspec.yaml instead of being whatever that
machine happens to have. The command becomes:
dart run xtask:xtask <task>
Your own entry point
The second, and the only thing here that needs Dart code, is a verb: a function of your own for a job with real logic in it. An engine somebody else shipped cannot contain your function, so you hand it over from a file of yours:
dart run :xtask <task>
The colon is the whole difference between the last two, and it is easy to read
past: what is written to the left of it is which package the executable comes
from, and an empty left side means yours. Without a bin/xtask.dart of your
own the short spelling fails with Could not find bin/xtask.dart in package <yours>, which is a truthful error and a baffling one if nobody said the file
was optional.
dart install xtask puts a real xtask on the PATH, compiled, and for a
repository whose tasks are all run: that is the pleasant way to work.
It stops working the moment a project registers a verb, and that is the
design rather than a limitation. What gets installed is this package's own
entry point, and it passes no verbs, because it cannot know yours: do: notify
then meets "the engine ships no project verbs", correctly, since the notify
the file means is a Dart function in your repository and not in the tool. That
is why the entry point belongs to the project — a global install is the engine,
and dart run :xtask is the engine plus what you wrote. The second thing is
also pinned by your pubspec.yaml, where a globally installed tool is a version
of its own that no repository can see.
dart run pays the JIT's start-up — around half a second, every invocation. It
is nothing against a gate that spends seconds inside a test runner, and it is
the entire cost of --gate-members, --why or --dry-run, which is where a
shell loop or a file being written notices it. dart compile exe bin/xtask.dart removes it. The binary still reads xtask.yaml at run time, so
tasks, gates and sets keep changing without recompiling; verbs are Dart, so a
binary holds the ones it was built with and wants rebuilding after one changes.
This repository keeps its own invocation as the aot task rather than a second
copy in this file — xtask --dry-run aot prints it.
The rest of this README writes the short spelling, because this repository has
a bin/xtask.dart. If you installed the engine and wrote no Dart, read every
dart run :xtask below as plain xtask: the flags and the file are the same,
and only the way the program is reached differs.
import 'dart:io';
import 'package:xtask/xtask.dart';
Future<void> main(List<String> args) async {
// Assigned, not discarded. `runXtask` answers with the exit code below, and
// `=> runXtask(args)` throws it away — the process then reports success for
// every outcome, including the two that mean the file is wrong.
exitCode = await runXtask(
args,
verbs: {
'regen': regen,
},
);
}
A verb is ordinary Dart — testable, typed, debuggable:
Future<int> regen(VerbContext context) async {
context.log('regenerating ${context.args.length} files');
// context.member which member of `each:` this run is for, or null
// context.run(...) a program, started the way a `run:` body is —
// PATH, PATHEXT, the batch rule, the exit codes.
// `workingDirectory:` is a path from the repository
// root, and stays inside it; left out, it is the task's
// context.args `args:` with `$all` expanded, then anything
// the command line passed after `--`
// context.env this machine's environment, with `env:` winning a clash
// context.workingDirectory
return 0;
}
The engine ships no project verbs — regen above is one repository's
business, not the tool's. Its only built-in is remove.
The file name is the declaration: dart run :xtask resolves to
bin/xtask.dart and nothing else, so nothing has to name it to make that work.
(pubspec.yaml does carry an executables: entry, and it is there for
dart install — which fails outright without one — not for dart run.)
The command
xtask <task> run a task and everything it needs
xtask <task> -- <args> and pass those arguments to its body
xtask <task> --keep-going report every failure, not just the first —
across tasks and across an `each:`
xtask <task> -j <n> run n at once — which costs seeing their
output as it arrives. `-j auto` picks one
xtask --list every task, grouped under its gate set,
in the order `gates:` declares
xtask --list --gate <name> only the tasks in that gate set
xtask --gate-members <name> the tasks in that gate set, one per line
xtask --why <task> what puts that task in a plan, and by which
`needs:` or `then:`
xtask --validate parse and check the file; run nothing
xtask --check-ci does the CI file still run the gate sets?
xtask --dry-run <task> print the resolved plan; run nothing
xtask --emit-schema print the JSON Schema for this file format
xtask --version print which engine this is
xtask above is whichever spelling you arrived at — the installed command,
dart run xtask:xtask, or dart run :xtask. The flags are the same in all
three. The file is looked for from the current directory upwards, so the
command works from a subdirectory and every path inside the file stays relative
to the repository root.
Everything after -- reaches the body of the named task, after its args:
and its expanded all:, and nothing else in the plan sees it — so
xtask test -- -n "one test" narrows the tests without also handing -n to
the formatter. A task with no body of its own is refused rather than
swallowing them.
Seeing what will happen
Two questions the tool answers without running anything. Both are cheap, and both are the fastest way to find out that a file says something other than what you meant.
--dry-run shows what will actually happen, not what is written — sets
expanded, $each substituted, and the executable resolved on this machine.
The task names below are this repository's own, from xtask.yaml:
$ xtask --dry-run check
plan: format, analyze, test
format
run /opt/homebrew/bin/dart format --output=none --set-exit-if-changed .
in /Users/you/xtask
analyze
run /opt/homebrew/bin/dart analyze --fatal-infos
in /Users/you/xtask
...
The plan names the tasks, not the gate set: check is what you asked for, and
what a run does is its members.
A do: remove block goes further and says what it would delete. The one verb
this engine ships is the one that deletes recursively, and a plan showing only
the pattern told you least about the operation you most need to check. In a
project whose file has a clean task built on the remove verb:
$ xtask --dry-run clean
plan: clean
clean
do remove build coverage **/*.tmp
in /home/you/that-project
del build
del coverage
del src/a.tmp
Nothing is run to work that out — it is the reading the verb itself does — and a tree that is already clean says so rather than printing nothing.
Because it resolves them, --dry-run answers 3 when a program is not
installed yet, naming the one it could not find. That is the same answer a real
run would give, one step earlier — which is worth knowing before you read it as
a bug on a machine where the tools are not set up.
--why answers the other direction — not "what does this run" but "why does
this run at all". It names each entry point that reaches the task and spells
the route edge by edge, saying which kind each edge is, because "it runs before
this" and "it runs after this" are opposite answers:
$ xtask --why test
gate check
gate check runs test
test
nothing else names it: `test` is where a run starts
Two routes, because there are two: the gate set reaches it, and so does
somebody typing its name. An edge says which kind it is — runs, needs,
then — because "it runs before this" and "it runs after this" are the two
answers a single word would blur.
Gate sets, and CI
A gate set is named after who runs it — one person's command, or one CI job's. Every set the file has is declared once, at the top:
gates: [check, release]
Names only — a gate set is not a task and has nothing to describe. Declaring
them is what makes a misspelling a refusal: a gate that came into existence by
being mentioned could not be misspelled, because the misspelling was a new
gate. The order is the author's, and is the order --list groups by.
A task lists the sets it belongs to, and xtask check runs every task in
check, in the order they appear in the file (cheap gates before slow ones) —
except where a needs: or a then: between two of them says otherwise, since
"it runs before this" is an order too, and the one the file states outright.
A declared set nothing is in is refused, for the reason the empty-set rule
exists: a gate that examined nothing reports the same green as one that passed.
There is no composite task, and nothing to keep in step with the declaration. A
gate set and a task may not share a name — a person types one word — and
needs: may not name a gate set, because an edge runs between tasks and a gate
set is a list rather than a step.
That is the whole mechanism for removing the duplicate list. A CI job runs one invocation:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- run: dart run :xtask check
Run-once still holds, because a job is one invocation. Parallelism is preserved, because it comes from the jobs the CI system already schedules. And a failure is still legible: on a host that folds output — GitHub Actions today — each task is a collapsible section, and the failing one is annotated with the command and the directory, so the line that says a task failed is also the line that reproduces it.
--check-ci keeps that arrangement from rotting. It reads every file under
.github/workflows — GitHub Actions is the only host it knows, and a
repository without that directory is told so rather than passed — and compares
what it finds with the gate sets in both directions: a run: step that is not
one invocation of one gate set is refused, because that is exactly how the
duplicate list grows back — somebody writes - run: dart analyze instead of
adding a task. A gate set no job runs is reported rather than refused: gate
sets are named after who runs them, and that is the jobs plus the people,
which nothing in the file distinguishes. A step that asks xtask a question —
--validate, --check-ci itself — is reported the same way: it names no
command that could drift, so there is nothing to move into the file.
The rule is blanket, and the exception is written where the exception is: on
the step's own run: line, after the command.
- run: dart run :xtask check
- run: npx playwright install --with-deps # xtask: not a gate — the browser driver, which no action installs
That is the one place the marker is read. A step is judged as written: the
whole of its run: is one command line, and it either is one invocation of
one gate set or it is not. Nothing here reads shell — no quotes, no &&, no
cd — because every reading of shell a checker attempts is a way for a job
that runs nothing to be counted as running something. So a run: | block of
more than one line is a script, and a script is reported rather than read; a
step with ${{ … }} in it is reported rather than guessed at; and a mention
of xtask anywhere but in command position — echo run xtask check,
timeout 600 ./xtask check — is a command like any other. Put the marker
after the | to excuse a whole script, use working-directory: rather than
cd, and put a timeout: on the task rather than around the invocation. A
step with an if: is reported with its condition beside the gate it runs,
since whether the condition holds is not something this file can say.
The reason is required. A marker with nothing after it is refused, because a marker with nothing after it is what this becomes when it is reached for to make a red gate green.
And it only excuses a step that would otherwise be reported as a command, which is the one thing it claims: that this step is not a gate. On a step that does reach xtask it is refused, whatever it says — one that runs a gate set, one that names a gate set under a mode, one that asks a question, one the command line itself turns away, one that names a gate set with a typo in it. Otherwise the marker is a way of making a job that runs nothing pass, which is the failure this whole mode exists to catch.
Every exemption is printed with its reason next to the jobs that passed, so a
workflow that has quietly exempted its way to green says so in the same
breath. And a workflow whose every run: step is exempted invokes xtask
nowhere, which is refused as it was before: the marker cannot stand in for the
invocation.
The rule stays blanket rather than growing a sense of which steps are "infrastructure" because no tool anywhere has one: the axis does not exist, and a checker inventing it would be classifying shell — the second grammar this mode is written not to keep.
It does not generate the workflow. Doing that would mean generating the
checkout, the toolchain and the artifact upload too, which needs a template
inside xtask.yaml — and templating is where an expression language starts.
The workflow file still owns what must exist before anything runs: the
checkout, the toolchain, a browser driver. xtask owns what runs. There is no
key that installs something; the one thing there is, is a precondition check:
web-e2e:
desc: browser e2e for the web binding
gate: [ci-web]
env-required: [CHROMEDRIVER]
in: packages/lake
run: [dart, test, test/web/web_e2e_test.dart]
which turns "a browser test failed somewhere inside" into "task web-e2e
requires CHROMEDRIVER, which is not set".
interruptible: true is the third of these, and it gives back what -j
otherwise costs. A run does not reach into what is already running, because a
build killed half-way leaves whatever it was doing in whatever state that half
is. That is right for a build and wrong for a check: dart format --output=none, dart analyze and dart test write nothing a half-run would
leave behind, and the engine cannot tell the two apart while the person who
wrote the task can. Sequentially a format failure at 0.4s means the rest never
run; in parallel they run to the end anyway and the machine spends the whole
budget to learn what it knew in a tenth of a second. With the key, the fast
answer arrives at the fast answer's price. --keep-going stops nothing at all,
which is the whole of what that flag says.
-j says how many; the file says whether. serial: true is one task
whose members must not overlap — six packages sharing one ~/.pub-cache, or
one git index, where git add fails outright rather than waiting.
exclusive: [chromedriver] is the same fact between tasks: two suites the
graph calls independent may still drive the one browser on the machine — and
because the token is held by the task, a task that holds one runs its own
each: members one at a time as well. Naming a browser and then driving it
from four members at once would be the guarantee said and not kept. Both
can only ever make a run slower, never change its result, which is the right
property for something every machine reads — and getting them wrong makes a
run flaky, which is a different kind of wrong from making it slow. A number
would be one machine's width written into a file the rest of them share, so
there is no key for it.
Reading a run
What a run tells you while it happens and when it ends — and what changes about that when you ask it to go faster.
A run has one duration, which answers nothing on its own, and a CI job that is one invocation has only that one. So the run prints what each task took at the end — after the last section, because a line inside a fold is invisible in exactly the state somebody is in when they want a number:
format 0.4s
analyze 2.3s
test 11.7s
total 14.4s
--keep-going is for the local loop. A gate that stops at the first failure
makes you fix, rerun, fix, rerun — the same argument --validate is built on,
which is why it collects every problem rather than throwing at the first. With
the flag, independent tasks still run and the run ends with a summary:
failed lint (exit 1)
failed unit (exit 1)
skipped check — needs `lint`, which did not pass
A task whose requirement failed does not run: its own failure would be a consequence of the first one. It is named as skipped rather than dropped, because a task that silently did not happen reads exactly like one that passed.
It is off by default, because a pipeline wants the earliest possible red rather than a broken run read to the end.
-j <n> runs tasks that do not depend on each other at once — and the members
of one each: at once. -j auto is this machine's processors, capped at 8,
because one unit here is a whole dart test or dart analyze rather than a
thread; -j N is the number you write. It is not the default, and the
reason is a real cost rather than caution: normally a task's output passes
through as it arrives and each task is a section that folds, and two tasks
writing to one terminal at once break both — the transcript belongs to neither.
So a parallel run collects each task's output and prints it whole when that
task ends. You get the answer sooner and you watch it happen less — and it says
so on the first line, because a run that goes quiet for eight seconds without
explaining itself is indistinguishable from one that has hung.
The summary then says both numbers, because they answer different questions:
lint 1.0s
unit 1.0s
types 1.0s
total 3.1s spent, 1.1s taken
Declaration order still decides which of the ready tasks starts first — cheap gates before slow ones — but nothing makes them finish in that order. A failure stops what has not started; it does not reach into what is running, because killing a task would leave whatever it was half-way through in whatever state that half is. Whichever way it ran, the summary then names what did not run:
failed format (exit 1)
skipped analyze — the run stopped at an earlier failure
skipped check — needs `format`, which did not pass
Exit codes
An exit code is not a success flag; it is the shortest possible bug report.
| code | meaning |
|---|---|
0 |
everything asked for ran and passed |
1 |
a task ran and failed |
2 |
the request was refused — a bad document, an unknown key, a cycle, a dangling reference, a set that expands to nothing; and a command line the parser turns down, or a --check-ci that found something |
3 |
a task's executable was not found |
4 |
a task's body succeeded and one of its then: continuations failed |
A 4 stops the run exactly as a 1 does: what has not started does not
start, what is running is left alone, and the summary names the rest. The code
says which of the three endings happened, not how much of the plan was
abandoned — those are different questions and --keep-going is the one that
answers the second.
With --keep-going and more than one failure, the code is the first
failure's. A code is a report about one failure, and a run with three cannot
honestly claim to be about all of them; the summary is where the others are.
3 is separate because "Dart is not installed on this machine" and "the code is
broken" are repaired by different people, and one code sends both to the same
one.
A verb's exit code is what the run answers with — it is your Dart, written
against this table, whose constants the package exports as ExitCode so a
verb can name the reason rather than the digit; and the built-in remove answers 2 for a path outside the
repository because that means the file is wrong. A program started by run:
has never heard of this table, so its code goes in the message and the run
answers 1.
4 exists because a publish followed by a verification has three endings,
not two: nothing was published, everything passed, or the upload happened and
the check after it is red. Collapsing the third into 1 tells a pipeline the
publish failed, which is false and unrecoverable in the wrong direction — the
registry will not accept that version again.
The keys
| key | meaning |
|---|---|
desc |
required, one line, what --list prints |
run |
an external program as argv — the program, then its arguments, each its own entry. Never a command line; nothing splits a string and no shell sees it |
do |
a verb: remove, or one this project registered |
args |
extra arguments appended to the body |
all |
a set whose members replace the $all marker, in one invocation — a whole argument, written once |
each |
a set whose members the body runs once per, with $each standing for the member |
in |
where the body runs, relative to the root — may end with $each |
env |
environment for this task only |
env-required |
variables that must already be set, checked before the body runs |
needs |
direct requirements, run before this task, once per invocation |
then |
continuations, run after this task's body |
gate |
the gate sets this task belongs to |
serial |
this task's each: members must not overlap, whatever -j says |
exclusive |
tokens this task holds alone while it runs — which makes its own each: members serial too |
interruptible |
a failure elsewhere may stop this task where it stands |
timeout |
seconds a run: body may take before it is killed — per member under each: |
timeout: is asked of the process, not waited out by the engine: the body is
sent SIGTERM, given a moment to write what it has, and then SIGKILL. What it
does not do is reach the process's own children — Windows has job objects,
POSIX has process groups, and neither is what Dart exposes — so a task that
spawns a server and hangs may leave the server behind. A do: cannot carry a
timeout: at all: a verb is a Dart function, nothing outside it can stop one,
and a limit that passed while the verb kept writing to disk would be worse than
none. That is refused when the file is read, not discovered at runtime.
The example at the top uses three keys because three is what that repository
needs. Here is one using the rest — a release, which is where needs:,
then: and a verb all earn their keep at once:
version: 1
sets:
packages:
include: [packages/*]
tasks:
build:
desc: build every package
each: packages
in: $each
run: [dart, run, build_runner, build, --delete-conflicting-outputs]
publish:
desc: publish, and announce it only if that worked
needs: [build]
then: [announce]
env-required: [PUB_TOKEN]
timeout: 600
run: [dart, pub, publish, --force]
announce:
desc: post the release note
do: notify
all: packages
args: [$all]
each: runs the body once per member — in the order the set gives them, and
one at a time unless -j says otherwise. $each is that member,
and it may stand as a whole argument or end one — packages/$each,
--flavor=$each — with nothing after it. That line is the same line twice: a
prefix lets a set hold the part a path cannot be derived from, the bare name,
so one task can have both in: packages/$each and --name $each; and no
suffix is what keeps build/$each.dart out, because deriving a path from a
value is a computation, a computation wants a modifier, and a modifier wants a
language. Deriving belongs in a verb. needs: is "before, and once however
many tasks ask for it"; then: is "after, and only if the body worked" —
which is the whole reason exit code 4 exists, because publish succeeding
and announce failing is a third ending and not a failure to publish.
env-required: is checked before that task's body runs — not at the start of
the run — so a missing token is a sentence rather than a broken upload. do:
names a verb the project wrote in Dart and handed to runXtask, and
all: hands it the expanded set, wherever $all is written.
A set is a list of paths, a glob with exclusions, or values: for members that
are not paths at all. The first two are expanded by the engine rather than by a
shell, in a deterministic order:
sets:
packages: [packages/lake, packages/lake_cli]
sources:
include: ['{templates,packages}/**/*.lake']
exclude: ['**/test_data/**']
flavours:
values: [dev, staging, prod]
**/ means none or more directories, as bash and git read it — so
packages/**/*.lake finds packages/x.lake too. That is two readings of one
pattern: it with the **/ kept, and it with the **/ dropped. A pattern
carrying more than a handful of them is refused rather than matched, because
every reading becomes a glob compared against every file the walk touches. The
readings are counted rather than estimated as 2ⁿ — they collapse, and
a/**/**/b has three and not four — so the limit turns away what is actually
expensive and nothing else.
values: is a declaration, not decoration. Every other kind of set holds
paths, and the engine treats them as paths — it refuses one that reaches
outside the repository, and it can say a glob matched nothing. Neither
question means anything about dev, and asking the first refused a:b for
looking like a Windows drive. It is also what lets a set hold the bare name a
path cannot be derived from, with in: packages/$each composing the path
around it.
A member a glob found that begins with - is refused where it would reach
a program's arguments: a file called -n.dart is a path to you and an option to
almost everything else. Write -- before the marker, where a command line says
its operands begin — the engine will not add it for you, because that would
change the argv the task wrote. A member you wrote out yourself is not refused:
you can see it, and a values: set holding -v is somebody passing a flag on
purpose.
A set is read when the task that names it is about to run, and at no other
time. --validate and --dry-run happen before that, so a set the run itself
produces says so:
sets:
generated:
include: ['build/**/*.dart']
produced-by: codegen
That buys exactly one thing: the emptiness of this set is not judged before
codegen has run. --validate passes over it and --dry-run prints cannot be resolved yet with the reason under it, instead of calling a working file
broken. Everything else about the set is still checked by both — a pattern that
leaves the repository, a pattern that is not a pattern — and a run still
refuses it empty, whatever was hoped for it.
The producer is named rather than flagged, because a name is something the
file can be checked against: every task that reads the set has to reach
codegen through needs:, or --validate says so. In the file's order the
producer may well come first anyway; under -j nothing but the edge says so,
and a reader that does not need its producer reads a set that is not there
yet.
A set that expands to nothing is an error: a task given no files checked nothing, and a gate that examined nothing is worse than no gate.
Editor support
xtask --emit-schema prints a JSON Schema for the file format. Generate it into
your repository and point at it with a relative path, so a fresh clone needs
neither the network nor a per-person editor setting:
xtask --emit-schema > xtask.schema.json
# yaml-language-server: $schema=./xtask.schema.json
version: 1
The schema knows the shape of the file: it completes a task's keys, and
underlines dsec: or a gate: written as a string, while you type. Everything
that needs the graph or the filesystem — a cycle, a needs: pointing at
nothing, an undeclared or empty gate set, an orphan gate, a glob matching
nothing, an unregistered verb, an in: that reaches outside the repository —
is what --validate answers. A schema catches a mistyped key; --validate catches
a mistyped name.
The schema describes one version of the engine, which is why it is generated into your repository rather than fetched from a URL.
Three rules
Not style. Each prevents a failure that has already happened somewhere.
R1 — no control flow in the file. No conditionals, no branching, no shell, no capturing one command's output to feed another. A task that needs a condition becomes a verb. The moment the file can ask "did that work?", it is a programming language with no debugger and no types.
R2 — no inheritance. A task is read completely from its own keys. This costs repetition and buys the property that what is written is what happens — and it keeps the engine from growing precedence order and "where did this value come from" tooling.
R3 — a built-in primitive is total and argument-driven. It takes paths or
values and performs an effect; it never branches on the result of anything.
remove: [paths] qualifies, test -f X && Y does not. This is what stops the
primitive list from becoming a portable shell — the failure the npm ecosystem
took, one package per utility (rimraf, mkdirp, cross-env, shx), all of
them existing only because package.json scripts are shell.
What it deliberately is not
- Not a build system. No up-to-date checks, no artifact graph, no caching. An expensive task solves that inside its own verb.
- Not a package manager and not a monorepo tool.
melosruns shell across packages;xtaskruns a graph without one. They do not overlap. - Not parallel by default. Tasks run in order, one at a time, and
parallelism belongs to the CI system, which already has it.
-jis there for the local loop and costs watching the output arrive. - No plugins, no dynamic loading, no expression language. Verbs are code the project links; everything else is data.
- No templating or interpolation beyond
$each. The moment a value can be computed in the file, R1 is gone.
Windows
run: is argv, and the engine resolves the program itself — walking PATH,
honouring PATHEXT, and knowing that CreateProcess cannot start a .bat or
a .cmd however it is asked. A shim goes through the shell because there is no
other way; an argument that the shell would reinterpret is refused with the
character named, rather than passed through to mean something else.
Windows has no argv. CreateProcess takes one string, and the runtime at the
other end splits it again — so an array is a promise somebody has to keep by
quoting. Dart's Process does that, by the rules CommandLineToArgvW reads
back, and a task written as a list arrives as that list: a path with a space in
it stays one argument and is not two. Where quoting is not enough the engine
refuses rather than hopes, and that is the batch shim above — cmd.exe parses
the line a second time, after the quoting, by rules of its own.
License
MIT — see LICENSE.
Libraries
- xtask
xtask— a task runner whose tasks are data.