Reference
uf.config.js
The only config file. It is Flow, so defineConfig type-checks it where you
write it, and every key below has a default — a project that wants uf's
opinions can leave the file nearly empty.
// @flow
import { defineConfig } from "@uniflowed/config";
export default defineConfig({
app: {
router: { entry: "app.js", root: "app" },
},
});
uf inspect --json prints the config as uf resolved it, defaults filled in,
which is the fastest way to answer "what is it actually using".
Top level
| Key | What it configures |
|---|---|
app | The application: router, runtime, React, rendering, built-ins |
build | The production build: entries, output, budgets, source maps |
builder | Which builder uf drives. Vite by default |
dev | The dev server: host, port, file access, allowed hosts |
docs | The documentation build, if the project has one |
site | Where the built site is served from, and what uf build writes for a crawler |
env | The .env files, the default mode, and the pinned toolchain |
fmt | uf fmt |
lint | uf lint and Flow's built-in lints |
test | uf test |
permissions | What the project's own code may reach. Absent means no limit |
plugins | Vite plugins, appended to uf's own |
vite | Vite's own configuration, merged over uf's |
tasks | Named tasks for uf run |
pm, publish, package, release | Which registry uf reads from and publishes to, and how a release is built |
server, story, std, vrt | The server adapter, stories, the standard library, visual tests |
app
| Key | Default | Meaning |
|---|---|---|
router.entry | "app.js" | The module exporting routerView() |
router.root | "app" | The directory scanned for routes |
router.enabled | true | Turning it off makes the project a library, and makes uf build build one |
runtime.default | "node" | The JavaScript host to prefer |
runtime.capabilityJsHost.hosts | ["node", "deno", "bun"] | Hosts uf may use |
runtime.capabilityJsHost.autoDetect | true | Pick a host by what is installed |
runtime.deploy.adapter | none | The deploy target uf build writes an artefact for; --adapter overrides it |
runtime.deploy.adapters | ["node", "edge", "serverless", "static", "container"] | The targets this project may build for. Five by default, because five are implemented; naming bun or deno is an error that says why (#391) |
runtime.deploy.enabled | true | Setting it to false refuses --adapter rather than ignoring it |
react.* | — | React Compiler mode and React-specific options |
react.strictMode | true | uf dev hydrates inside <StrictMode>; a build never does (#516) |
builtins.markdown | on | Markdown and MDX |
builtins.markdown.mdx.highlight.enabled | true | Highlight fenced code during the build |
builtins.markdown.mdx.highlight.themes | github-light / github-dark-dimmed | The theme for each of the reader's two preferences |
builtins.markdown.mdx.highlight.langs | [] | Grammars beyond the ones a uf project uses |
builtins.fonts.subset | "none" | "ranges" splits a family into script buckets with exact unicode-range values. "none" is the default because subsetting is lossy and a build cannot see the text a user will type; "ranges" loses nothing (guide) |
builtins.fonts.preload | true | Whether Font preloads the primary face. Exactly one face is preloaded however many buckets a split produced |
builtins.icons.enabled | true | Whether uf:icon/… resolves |
builtins.icons.dir | "icons" | Where uf:icon/<name> looks for <name>.svg. A directory rather than a claim on .svg, which stays Vite's |
builtins.og.enabled | true | Whether *.og.json templates are drawn |
builtins.og.font | none | The typeface every card is drawn with. uf embeds none, so a project that draws cards names one |
builtins.reactCompiler | on | The official React Compiler |
builtins.style | — | The style engine |
rendering.modes | ["ppr", "ssr", "ssg", "isr"] | Which rendering strategies this project will deploy — see below |
rendering.cache.* | all false | Which of uf's caches are on; data and actions are refused rather than ignored |
targets | — | Runtime targets the build must satisfy |
rendering.modes
An allowlist, not a request. It does not ask for a strategy; it says which
ones this project is willing to deploy, and uf build picks between them per
route: a route with no parameters is prerendered, a route with parameters is
prerendered when its page exports generateStaticParams, and anything left
over is rendered per request.
That matters because the two answers are deployed to different places. A prerendered document is a file a CDN serves; a route rendered per request needs a process.
| The list | What uf build does |
|---|---|
contains ssg and ssr (the default) | Prerenders what it can; the rest are rendered by uf start, uf preview or a deploy adapter |
["ssg"] | Prerenders everything, and fails, naming the route, if anything can only be answered per request |
["ssr"] | Prerenders nothing. Every route is rendered per request |
ppr and isr are Planned. Naming one alongside an implemented mode is
allowed and selects nothing; a list that names only planned modes is refused
while the config is read, because there is no build behind it.
Three things can only be answered per request, and under ["ssg"] each is an
error naming the route: a page with parameters and no generateStaticParams, a
_uf.route.js handler, and anything under a _uf.middleware.js.
A page can also say so for itself:
// app/now/_uf.page.js
export const dynamic = "force-dynamic";
which is how a route with no parameters keeps out of the prerender —
generateStaticParams cannot say it, because there are no parameters to
generate. "auto" is the default. Next.js's "force-static" and "error" are
constraints uf does not check yet, and a page that names one is refused rather
than quietly rendered as "auto".
dev
| Key | Default | Meaning |
|---|---|---|
host | "127.0.0.1" | Interface to bind |
port | 5173 | Port to listen on |
strictPort | false | Fail rather than move to the next free port. uf dev --port is always strict, whatever this says |
allowedHosts | [] | Host headers accepted when binding a routable address |
allowedOrigins | [] | Origins accepted for cross-origin dev requests |
fs.allow | [] | Extra roots the dev server may serve from |
fs.deny | [] | Globs it may never serve; deny beats allow |
--host refuses to bind a routable address while allowedHosts is empty. A dev
server reachable from the network with no host allow-list is a file server for
your source tree; the reasoning is in
docs/security.md.
build
| Key | Default | Meaning |
|---|---|---|
entries | ["app.js"] | Entry modules |
outDir | "dist" | Output directory |
sourcemap | true | Emit source maps |
staticBuild | false | Prerender everything and emit no server bundle — see below |
lib | unset | What a library build writes — see below |
budgets | unset | Size budgets that fail the build when exceeded |
hooks | {} | Commands to run around the build |
Budgets are unset on purpose: failing a build nobody asked uf to police is worse than reporting the size and moving on.
build.lib
uf build builds two different things, and which one it builds is
app.router.enabled. A project that has turned the file-system router
off is a library: uf build compiles its entries to the output directory
and does none of the application work — no route table, no client entry, no
server bundle, no prerender. uf explain build says which build will run and
which key decided.
That is the whole declaration. build.lib is only for a library that needs
more than the defaults, and the defaults are what uf new --lib scaffolds:
| Key | Default | Meaning |
|---|---|---|
entries | ["index.js"] | The modules to build. internal/parse.js is written to dist/internal/parse.js — the path, so two entries cannot collide |
formats | ["es"] | "es" writes <name>.js; "cjs" writes <name>.cjs, for a consumer that calls require. "umd" and "iife" are refused by name: each needs a global name per entry, and what a Flow library's global should be is not a decision uf has made |
external | [] | Extra package names to leave as imports |
Dependencies are external by default, which is the opposite of the
application build: everything in dependencies, peerDependencies and
optionalDependencies, plus the host's built-in modules, stays an import. An
application is the end of the line and a library is not — a bundled copy of
React inside a library is a second React in every application that installs it.
external is for what a manifest cannot say.
build.lib in a project whose router is on is refused while the config is
read. One project is one kind of build, and two keys disagreeing about which is
exactly the failure that refusal exists for.
uf build --compile and uf build --adapter are refused for a library, by
name and before anything is built. Both write a deployment that serves an
application; a library is imported.
What a library publishes
A Flow library ships both halves — the Flow source, and the JavaScript uf compiled from it — and names them with export conditions:
{
"exports": { ".": { "flow": "./index.js", "default": "./dist/index.js" } },
"files": ["index.js", "dist"],
"sideEffects": false
}
There is no .js.flow, here or anywhere in uf: a shipped module owns its own
declarations, so the Flow source is the declaration file.
default is the compiled build because a resolver that knows no conditions
takes default, and that file has to be one every runtime can evaluate — which
is what makes a uf library installable by someone who has never installed uf.
files has to name the output directory: npm falls back to .gitignore when a
package has no .npmignore, and dist/ is in the .gitignore uf scaffolds,
so a library that left it out would publish the source and not the build.
The full argument, including why uf's own @uniflowed/* packages ship source
and nothing else and why a uf application does not select the "flow"
condition yet, is in
Architecture.
build.staticBuild
The same declaration as rendering.modes: ["ssg"], made about the artefact
rather than about the routes: this build prerenders every route, and what it
leaves behind is documents.
The server bundle is still built — the prerender renders through it — and then
removed, so .uf/build/server/ is empty when the build finishes. Two things
follow:
uf startrefuses, by name, rather than failing later on a missing file. It is the one command that loads that bundle, and for a project deploying documents there is no deployment that runs uf at all. Deploy the output directory to any static host, or runuf preview, which serves it the way one would.- a
_uf.route.jsor a_uf.middleware.jsanywhere in the project is a build error, which is the refusal #342 asked for: a guard that can never run is a guard that looks enforced and is not. - a
"use server"export the browser can reach is a build error too, and it is the one that is not a route. The client bundle carries a server reference for it — an id and afetch— so the button is wired whether or not anything answers; a deployment of documents is the case where nothing does. Keep the module out of the client's reach, or dropstaticBuild.
uf build --adapter <target> and uf build --compile still work, and that is
not an oversight. Each links the application again from source and reads none
of the removed bundle, so each still produces a correct artefact — a static
site inside a Worker, or inside one executable, is a deployment somebody wants.
What staticBuild describes is what the ordinary build leaves behind. Those
two builds are also the ones the server-action refusal does not apply to: what
each writes can answer an endpoint, so there is no unanswered one to refuse.
staticBuild: true alongside a rendering.modes that does not allow ssg is
refused while the config is read. Neither declaration is wrong on its own and
there is no behaviour that honours both.
uf build also writes three files beside the output directory, in
.uf/build/meta/: uf-build-manifest.json, uf-rsc-manifest.json and
uf-bundle-report.json. They are notes about the build rather than part of it
— between them they name every route including the ones nothing links to, the
source file behind each one, and the size of every chunk — and everything in
the output directory is served to whoever asks for it.
site
| Key | Default | Meaning |
|---|---|---|
url | unset | The origin the site is served from, absolute and with no trailing slash |
sitemap | true | Write sitemap.xml |
robots.enabled | true | Write robots.txt |
robots.allow | [] | Allow: lines |
robots.disallow | [] | Disallow: lines |
site: {
url: "https://docs.uniflowed.dev",
},
url is the switch. Without it neither file is written, because a <loc> has
to be an absolute URL and nothing in a bundle knows the host it will be
deployed to — and a sitemap naming the wrong host is worse for a site than no
sitemap at all. With it, uf build writes:
sitemap.xml— one<loc>per document the prerender wrote, minus the error document and minus anything under a_uf.middleware.js. A route with parameters and nogenerateStaticParamsprerenders nothing, so it is absent by construction: nothing, this build included, knows what its URLs are. There is nolastmod,changefreqorpriorityon any entry — none of the three is a fact the build has, and alastmodthat is the deploy time for every URL is one search engines learn to ignore.robots.txt— but only when it would say something. Arobots.txtthat disallows nothing is exactly equivalent to not having one, so what makes it worth writing is theSitemap:line or a rule you asked for. With neither, no file.
A guarded route is left out of the sitemap and is not added to robots.txt:
Disallow: /admin publishes /admin to everyone who fetches the file, and a
path you did not want advertised is not made safer by being written into the
one file every crawler reads first.
A public/sitemap.xml or public/robots.txt you wrote yourself is never
overwritten. The build says which file it left alone.
The origin is not metadataBase, which is a route's own
metadata and is what the renderer resolves a
relative og:image against. The two are read by different things at different
times and a site that has both should keep them the same.
env
| Key | Default | Meaning |
|---|---|---|
active | "" | The mode when nothing else says. Empty means the command decides: development for uf dev, production for uf build, uf preview and uf start, test for uf test |
files | [] | The .env files to read, instead of the cascade. Empty means .env, .env.local, .env.<mode>, .env.<mode>.local, in that order, the later file winning |
toolchain | {} | The runtimes and package managers this project pins, by exact version — { node: "24.14.0" } |
--mode beats active, and so does the profile uf env use wrote. A variable
already set in the environment beats every file. Only a name starting with
VITE_ — or Vite's own envPrefix, if the project sets one — reaches browser
code; everything else is the server's. Environments is the whole
of it.
permissions
Absent, and there is no limit — the toolchain uf has always been. Present, and
it is deny by default: the project's code gets what this block lists and
nothing else. permissions: {} is a legitimate thing to write and means
"nothing beyond what uf itself needs".
| Key | Default | Meaning |
|---|---|---|
read | [] | Paths the project's code may read |
write | [] | Paths it may write |
net | [] | Hosts it may reach, optionally with a port |
env | [] | Environment variables it may read |
run | [] | Programs it may start |
// @flow
export default defineConfig({
permissions: {
read: ["./fixtures"],
write: ["./.uf"],
},
});
The set is uf's, not a runtime's, and each host is handed as much of it as it
can enforce — or refuses the run. Node.js enforces read and write; Deno
enforces all five; Bun has no permission model, so a project that declares one
and runs on Bun is stopped with a message naming the hosts that can.
uf explain test prints which host is enforcing what.
docs/hosts.md
is the whole table, including the two grants uf makes for itself on Node and why
they cannot be scoped.
Two things worth knowing before you write one:
- It does not narrow access to the project's own files. A host has to read the project to run it, so those reads are uf's and are added to yours. What the set denies is the rest of the machine.
- A misspelled key is an error. Every other block here ignores a key it does
not recognize; this one refuses, because a dropped
netwould leave a project believing it had declared a permission nobody granted.
fmt
| Key | Default | Meaning |
|---|---|---|
lineWidth | 100 | Where the printer wraps |
indentWidth | 2 | Spaces per level |
quotes | "double" | Quote style |
semicolons | true | Keep semicolons |
maxBlankLines | 1 | Consecutive blank lines kept |
flow.parser | "official-flow-rust" | The parser Flow files are formatted with |
nonFlow.formatter | "biome" | Who formats JSON, CSS and TypeScript: "biome", "prettier" or "none" |
nonFlow.arguments | [] | Extra arguments, passed to that formatter verbatim |
nonFlow.arguments is a list of strings and not a shape, on purpose. uf does
not re-declare another tool's options — every option it copied would be one
that needed a uf release before anybody could use it — and it does not have to,
because an argument list reaches every option biome or prettier has now or adds
later.
A Tailwind 4 project needs biome's CSS parser told about @theme:
export default defineConfig({
fmt: { nonFlow: { arguments: ["--css-parse-tailwind-directives=true"] } },
});
and needs no second configuration file for it.
The arguments go after uf's own, so they win: a project that wants a line
width other than fmt.lineWidth for its stylesheets can say so. The one thing
they may not do is turn a check into a write — --write, --fix and their
spellings are refused where the configuration is read, because uf fmt --check
changing files is the failure nobody sees: a green CI job and a rewritten tree.
lint
| Key | Default | Meaning |
|---|---|---|
engine | "rust" | The lint engine |
rules | uf's table | Rule name to "off", "warn" or "error", merged over uf's table |
files | all | Globs to lint |
ignore | [] | Globs to skip |
flow.builtins | "mixed" | Run Flow's own lints alongside uf's |
flow.parser | "official-flow-rust" | The parser used to read Flow files |
rules is changes to uf's table, not the whole of it. Naming one rule
changes that rule and leaves the rest at the level uf ships:
export default defineConfig({
lint: { rules: { "flow/unclear-type": "warn" } },
});
still runs every other rule, flow/syntax among them. To switch a rule off,
say so — "off" — because a rule you did not mention is not a rule you asked
to stop running, and a linter that quietly shrank to one rule reports a clean
project either way (#475).
ignore is read by uf lint, uf fmt, uf check and uf test alike, which
its name does not say — see the note below.
What counts as your project
Every command that walks the project — uf fmt, uf lint, uf check,
uf test, uf doc — reads the same three things, in this order:
-
.gitignore. A file git is told to ignore is generated output, and generated output is not yours to format, lint or check. This is the list you already keep, so the commands agree by construction rather than by four configuration keys you have to hold in step..ignorefiles are read too, and negations (!kept.js) work, because a.gitignoreis a program rather than a list and half-reading one is worse than not reading it.Your global gitignore is deliberately not read: two people on one repository have to get the same answer from
uf fmt --check. Neither is a.gitignoreabove the project root, which belongs to whatever the project is sitting inside. -
.ufand.git, always, whatever the configuration says. -
lint.ignore, for anything left. It is named after one command and read by all of them, which is a name that should change and has not yet (#575).
A directory holding a .git of its own is another repository — a submodule,
or a checkout that happens to live inside yours — and is never walked.
Naming a path overrides .gitignore. uf fmt src/wasm reads src/wasm
whether or not git is told to ignore it: the ignore file says what your source
is, not what you may ask about, and a generated file is exactly the thing you
point at when you want to know why it will not compile. uf lint, uf check
and uf test do the same, and it is where rg, prettier and biome draw the
line too. .uf and .git are the exception: pointing at them does not make
them yours.
Flow defaults every built-in lint to off. uf does not: a linter nobody
switches on catches nothing. Every rule's level and the reason for it is in
uf's rule table, and a test asserts the table matches the descriptors.
test
| Key | Default | Meaning |
|---|---|---|
module | "@uniflowed/test" | The module test files import |
runner.jsHosts | ["node", "deno", "bun"] | Hosts that may run workers |
runner.scheduler | "native-work-stealing" | How files are handed to workers |
runner.officialFlowParser | true | Discovery reads Flow with the official parser |
reactTestingLibraryNative | false | Whether component tests can run without a DOM |
coverage.enabled | false | Collect coverage on every run, without --coverage |
coverage.directory | "coverage" | Where the reports are written |
coverage.reporters | ["text", "lcov"] | text is the terminal table; lcov and cobertura are files |
coverage.include | [] | Keep only files whose path contains one of these; all of them when empty |
coverage.exclude | [".test.", ".spec."] | Drop files whose path contains one of these. A test file's own coverage is a hundred per cent by construction |
coverage.thresholds | {} | lines, functions, branches the project as a whole must reach, as whole percentages |
coverage.perFileThresholds | {} | The same three, required of every single file |
A threshold that is not reached fails the run. They are checked whenever
coverage was collected, so enabled: false plus --coverage still gates — the
number CI fails on is the number a laptop fails on. A metric nobody names is not
checked, which is not the same as requiring zero.
builder
Which builder uf dev, uf build, uf preview and uf start drive.
| Key | Default | Meaning |
|---|---|---|
module | "@uniflowed/vite" | The module implementing the builder contract |
export default defineConfig({
builder: { module: "./tools/my-builder" },
});
Vite is the default, not a dependency. A package name is resolved by
walking up node_modules the way a import is; a path starting with . or
/ is resolved from the project root and refused if it leaves it, which is how
a builder is tried before it is published.
A builder is a directory whose package.json declares one:
{
"uf": {
"builder": {
"driver": "./driver.js",
"preload": { "bun": "@uniflowed/host/bun-preload" }
}
}
}
driver is the module uf spawns on the project's Capability JS Host, and
preload names what to --preload when that host is Bun, which is how a
builder that transforms Flow installs its hooks on a runtime with no
module.register. Both are optional; ./driver.js is assumed when neither is
declared, so a builder published before this key existed still runs.
What uf hands that driver and what it expects back — the subcommands, the
arguments and the one-JSON-event-per-line vocabulary — is the contract, and it
is written out in
docs/architecture.md.
uf explain build names the builder that will run and its version.
Which build of a builder runs is the same key. uf pins the Vite that
@uniflowed/vite depends on, and a project that needs another one names a
module of its own — a directory that re-exports uf's driver beside the
dependency it wants — rather than forking uf. uf explain build prints the
version it resolved, so the pin is checkable without running a build.
Readiness. The seam is Implemented and @uniflowed/vite is the only
builder uf ships. A second production builder — Rolldown, rspack, esbuild — is
Planned and is separate work; crates/uf_cli/tests/fixtures/paper-builder
is a conformance fixture rather than a builder to use.
vite
Vite's own configuration, merged over the one uf generates. uf does not read it, which is the point: an option added to Vite tomorrow works in a uf project tomorrow rather than after a uf release that names it.
export default defineConfig({
vite: {
server: { warmup: { clientFiles: ["./src/big-module.js"] } },
build: { target: "es2022" },
resolve: { alias: { "~": "/src" } },
},
});
Objects merge key by key and arrays replace, so server.fs.allow means that
list rather than that list plus uf's. Three things stay uf's whatever this
says: root and configFile, because a vite.config.ts beside
uf.config.js is two files disagreeing about one project; and uf's own
plugins, which are kept and put first — dropping the Flow transform would
leave a project whose source no longer compiles, which is not something anyone
means to configure.
Everything else is yours to set, including options uf sets itself. A default is a convenience, not an architecture. The reasoning is in Architecture.
pm and publish
Two settings that used to be one. publish.registry is where uf publish
pushes a package; pm.registry is where uf reads — packuments for
uf update, provenance attestations for uf install, and the versions every
report is against.
| Key | Default | Meaning |
|---|---|---|
pm.registry | publish.registry | The registry uf resolves and reports against |
pm.scopes | {} | { "@company": "https://npm.company.example" } — which registry answers for which scope |
pm.provenance | "report" | "off" reads no attestations |
pm.lockfile | "uf.lock" | The lockfile uf install writes |
pm.storeDir | ".uf/store" | The content-addressed store |
pm.allowLifecycleScripts | false | Every dependency's install scripts, including the ones nobody has read |
pm.packageManager | "auto" | Override the manager uf infers from the project |
publish.registry | "https://registry.npmjs.org" | Where uf publish pushes |
Migrating from publish.registry
Nothing to do for a project that publishes to and installs from the same
registry — pm.registry defaults to publish.registry, so the behaviour is
unchanged and nothing is printed.
A project that has moved publish.registry off npmjs is being read from
through a key that means "publish here", and uf update says so once. Name the
two separately:
export default defineConfig({
// Where uf resolves, reports and reads attestations from.
pm: { registry: "https://mirror.company.example" },
// Where `uf publish` pushes. Unrelated, and it always was.
publish: { registry: "https://npm.company.example" },
});
publish.registry keeps working as the fallback; there is no release in which
setting only it stops resolving.
Binding a scope to a registry
export default defineConfig({
pm: {
registry: "https://registry.npmjs.org",
scopes: { "@company": "https://npm.company.example" },
},
});
A package in @company is resolved from npm.company.example and nowhere
else. There is no fallback to the public registry, and that is the point:
publishing @company/internal-thing to npmjs and waiting for a resolver to
fall back to it is the dependency-confusion attack, so a fallback would be the
vulnerability rather than a recovery from it. A name the bound registry does
not have is an error naming the scope and the registry.
uf install refuses a lockfile that resolves a bound scope from anywhere else,
before the manager runs. uf does not rewrite your .npmrc: the manager that
resolves is the one that has to be told, so bind the scope there too —
@company:registry=https://npm.company.example.
Provenance
uf install reads the npm provenance attestation of every package the install
brought in or moved. An attestation that is not about the tarball being
installed stops the install; a package that publishes none is a line in the
summary, because most of npm publishes none and a tool that refused them all
would be a tool nobody runs.
uf pm approve-builds shows the same thing in an attested column, which is
the moment it matters: that is when a package is about to be allowed to run
code on the machine.
What an attestation does and does not prove is in
Security.
pm.provenance: "off" turns the reads off for a machine with no route to a
registry.
tasks and plugins
export default defineConfig({
tasks: {
// A string is a command and nothing else.
lint: "uf lint",
// The long form takes five more fields, all optional.
check: {
command: "uf check",
cwd: "packages/core",
dependsOn: ["lint"],
env: { NODE_OPTIONS: "--max-old-space-size=4096" },
inputs: ["packages/core/**/*.js", "uf.config.js"],
outputs: ["packages/core/dist/**"],
},
ci: { command: "echo ok", dependsOn: ["check", "test"] },
},
plugins: [svgr()],
});
uf run ci runs the task, and everything dependsOn reaches before it. Tasks
with no path between them run at once; see
uf run.
inputs and outputs are paths or globs from the project root — * stops at
a /, ** crosses one, and a pattern beginning ! excludes what it matches.
A ** does not descend into .git, .uf, .uniflowed, dist,
node_modules or target, but a pattern that names a path inside one of them
— target/release/uf — still resolves.
inputs is what uf keys the cache on, so it has to be everything the task
reads. A task that declares none always runs, which is the honest default:
uf cannot see what a shell command opens, so a task that has not said is a
task uf will not guess about. cache: false opts a task that does declare
them back out; cache: true is that default said out loud, and is an error on
a task with no inputs rather than a request uf quietly ignores.
outputs is checked rather than restored: a cached result is replayed only
while the files it produced are still on disk with the contents it produced,
so deleting a dist/ rebuilds it.
Two things are deliberately not in the key. The ambient process environment
— a key over SHLVL and TERM_SESSION_ID would never be hit twice, so a task
whose answer depends on a variable names it in env, which is in the key. And
anything about the machine that is not a file: the Node on PATH, the C
compiler, the shell. A task whose answer depends on one of those and cannot
name it as a file should not declare inputs.
plugins are Vite plugins, appended to the ones uf installs; entries are
resolved by uf_plugin, which rejects any that reach outside the project root.