Reference
Commands
Every subcommand, what it does, and what it exits with. Global flags come before the subcommand; a subcommand's own flags come after it.
Global flags
| Flag | Meaning |
|---|---|
--cwd DIR | Treat DIR as the project root instead of the working directory |
--color auto|always|never | Colour output. auto colours a terminal and nothing else |
-h, --help | Help for uf or for a subcommand |
-V, --version | The version |
Project
uf new PATH [TEMPLATE]
Scaffolds an application into a new directory: uf.config.js, app.js, a
layout, a page and a package.json. Writes files and nothing else — no
install, no git.
react is the only template today, so uf new my-site is the usual form. A
word in the template's place that is not a template is refused rather than read
as a directory: uf new my-site raect says so, and scaffolding into raect/
would be the worse answer.
--name sets the package's name when it should not be the directory's, and
--force writes into a directory that already holds files.
uf init [TEMPLATE]
The same scaffold, into the current directory. That is the whole difference
between the two commands, and it is the reason there are two: uf create app
took one optional argument that was a template when it named one and a
directory when it did not, so the two things it could do had no separate
spellings.
uf create is still accepted and is no longer listed.
uf new NAME --lib
Scaffolds a library rather than an application: no router, no document, and a
package.json shaped for publishing. uf init --lib does it in place.
Its uf.config.js says app: { router: { enabled: false } }, and that one
line is what makes uf build build a library rather
than look for an application entry. The manifest it writes ships the Flow
source and the compiled build, named by export conditions; see
build.lib.
uf info
Prints the version, the resolved JavaScript host, and the project uf found. The first thing to run when something is behaving unexpectedly.
uf inspect
Prints what uf resolved from your config — the effective options, the route
table, and the config handed to Vite. --json for the machine-readable form.
uf explain COMMAND
Says what a command will do and which provider does each part — dev,
build, preview, start, doc, test, fmt, lint or check. --json
for the machine-readable form.
uf dev
1. configuration
provider uf
what uf.config.js, with `vite` merged over what uf generates
2. JavaScript host
provider node
3. dev server
provider vite (@uniflowed/vite driver)
4. Flow to JavaScript
provider uf transform (in this binary)
5. rendering
provider @uniflowed/router
configuration
- ./uf.config.js
- package.json
uf inspect answers "what is my configuration"; this answers the question
people actually ask when a command surprises them, which is "who is doing
this, and which file decided that". An integrated toolchain that cannot say
what it is doing is a black box — see Architecture.
uf routes list
Prints the route table uf build counts from: the path, the parameters it
captures, how many middlewares run before it answers, and the page file each
one resolved to.
It is the same discover_routes the build runs, so a route that is missing
here is a route that will be missing from the site. A directory uf reserves
without serving, or a [...rest] with a segment after it, is reported as the
error uf build would give rather than silently left out of the table.
uf routes add PATH [--layout] [--loader] [--middleware]
Writes a route: the directory, _uf.page.js, and whichever of the rest was
asked for.
uf routes add /articles/[slug] --loader
PATH is a URL path in the spelling the directories already use —
/articles/[slug], /docs/[...path], /(marketing)/about — so what you type
is what appears in RoutePath. The page is typed from the path: a [slug]
becomes +slug: string and a [...path] becomes
+path: $ReadOnlyArray<string>.
--loader is the one flag that is not a file. A loader is an export of the
page module rather than a name uf reserves, so --loader adds the export and
the data prop rather than writing a file of its own.
What it writes is exported by name — Page, Layout, middleware — which is
the spelling uf new uses and the one react/no-default-export-component does
not report. A scaffold that made uf lint speak on its first run would be
teaching that the rule is noise.
The file names come from the same grammar uf build discovers routes with, so
a scaffold uf writes is a route uf finds. A spelling uf reserves without
serving — @team, (.)photo — is refused here with the sentence uf build
and uf lint give for it, rather than written now and reported later. Nothing
is overwritten: a route whose page already exists is an error, and a run that
stops has written none of its files.
uf prepare regenerates router.js, so the new path joins RoutePath on the
next run of it.
Running and building
uf dev
Starts the dev server. --port PORT overrides dev.port, and fails if that
port is taken rather than moving to the next free one — a port you typed is the
port you meant, and a server listening one along is a server your bookmark, your
proxy rule and your container mapping cannot reach. dev.port keeps the moving
behaviour unless dev.strictPort says otherwise; it is a preference the project
wrote down once, not an instruction you just gave. --host HOST binds a
routable address, which requires a non-empty dev.allowedHosts.
--port 0 asks the operating system for a free one and prints the answer as the
local URL, which is the whole of what it is for: a caller that has to know the
port — a test, a script, a container that reads it back — otherwise has to pick
one, release it, and hope nothing took it in between. That race is real, and it
is why this exists; see ubugeeei-prod/uf#234.
--mode MODE runs in that mode instead of development, which is what chooses
.env.<mode> and what the browser reads as import.meta.env.MODE; see
Environments.
It also runs the server-component analysis, and runs it again whenever a module
changes, so a Server Component that reaches for useEffect or localStorage
is named where it was written rather than in a build days later. It is a report
and not a gate: uf dev does not refuse to serve a module over it, and nothing
here changes an exit code.
uf build
Builds the client and server bundles and prerenders what can be prerendered.
--size-report prints the cost of each chunk. A route that a
_uf.middleware.js guards and the prerender wrote a document for is listed
under guards, and in .uf/build/meta/uf-build-manifest.json as
prerenderedUnderMiddleware — a static host serves that file without running
the guard, and which half of dist/ you deploy is the one thing the build
cannot know.
Every route the build wrote no document for is listed too, under answered by a server and in the same manifest as rendering.perRequest: a page with
parameters and no generateStaticParams, a page that exports dynamic = "force-dynamic", a _uf.route.js handler. Those routes need a process, and
until they were reported the only way to find out that dist/ did not contain
one was a 404 after the deploy.
Which of the two a project will accept is
app.rendering.modes and
build.staticBuild. A project that has
said it deploys to a static host gets a build error naming the route
instead of a dist/ with a hole in it, and a project that has said it renders
everything per request gets no prerendered documents at all. The summary's
rendering row says which of the three happened.
The builder that does all of this is builder.module,
which is @uniflowed/vite unless a project names another; uf explain build
prints the one that will run and its version.
The build's own notes go in .uf/build/meta/ rather than in the output
directory: uf-build-manifest.json, uf-rsc-manifest.json and
uf-bundle-report.json describe the application instead of being part of it,
and everything in dist/ is served. What the build does write into dist/ for
a reader that is not a person is sitemap.xml and robots.txt, when
site.url says where the application is served
from.
--mode MODE builds in that mode instead of production: it chooses
.env.<mode> out of the cascade and is what import.meta.env.MODE reads, so
uf build --mode staging is how one project produces a staging bundle. See
Environments.
The two bundles are not the same route table. A route with no "use client"
module reachable from its page, its layouts or its fallbacks keeps its page out
of the client one, so nothing in the browser imports it and nothing only it
reached is emitted — its stylesheet excepted, which is still linked, because a
route that ships no JavaScript still has to look right. The summary says
pages in the client bundle N of M when that removed something, and says
nothing when it did not; uf-rsc-manifest.json carries the decision per module
as proximity.
Two flags write something you can take away with you: --adapter TARGET
writes a directory for one deployment target — a host with a JavaScript
runtime, a container image, a Cloudflare Worker or a Lambda function — and
--compile writes a single executable file that needs not even a runtime.
Both are described below, beside the two ways to serve a build in place.
Everything above is the build of an application. A project whose
app.router.enabled is false takes a different one.
uf build, for a library
A project with the file-system router off is a library, and uf build
compiles its entries to the output directory instead of bundling an
application. Nothing above happens: there is no route table, no client entry,
no server bundle, no prerender and no sitemap.xml.
uf build
build library
because `app.router.enabled` is false, so this project is a
library rather than an application
engine vite
entries index.js
formats es
external packages 0
Every package the manifest declares stays an import — dependencies,
peerDependencies, optionalDependencies, and the host's built-in modules —
which is the opposite of the application build and the reason a library does
not ship a second copy of React to everyone who installs it.
build.lib is where the entries, the formats and
any extra externals go, and its defaults are what uf new --lib scaffolds, so
most libraries write none of it.
--compile and --adapter are refused here, by name and before anything is
built: both write a deployment that serves an application, and a library is
imported. What sends a library anywhere is uf publish.
The build warns when package.json names an exports target inside the output
directory that it did not write — a package that installs and cannot be
imported, which every other check passes.
Serving a build
Three commands serve what uf build wrote. They are not alternatives you pick
by taste; they differ in what has to be installed on the machine that answers
the request.
| Command | Server | The host needs | Use it to |
|---|---|---|---|
uf preview | Vite's preview server | the project, its dependencies and Vite | check a build on your own machine |
uf start | uf's own node:http server | a JavaScript host, node_modules, and the build | serve from the checkout you built in |
uf build --adapter TARGET | the target's own entry, inside the output | whatever the target is | copy a directory to a host, or upload it |
uf build --compile | that server, inside the executable | nothing at all | deploy one file |
What they must not differ in is the answer, and they do not: an asset or a
prerendered document is served first, then a route handler is offered the
request for any method, and anything left is rendered. That order is Vite's
rather than a preference — its preview server runs its own file middleware
before anything uf mounts behind it — so uf start matches uf preview, and
every adapter's output and a compiled binary match both. A route that works
when you check it works when you deploy it, whichever of them you deployed
with.
They are the same code, not merely the same idea: the application is
@uniflowed/server/fetch in every one of them, and what differs is the host
half — @uniflowed/server/node for a socket and a directory,
@uniflowed/server/edge for a Worker, @uniflowed/server/lambda for an
invocation.
uf preview
Serves the build through Vite's own preview server, on 4173. It is the step
between uf build and deploying: the one place you find out that what worked
in uf dev also works bundled, minified, hashed and served from dist/.
It serves the whole build, not only the files. Vite's preview server is a
static file server, and a uf application is more than static files — a route
handler answers a POST, and a route with parameters and no
generateStaticParams was never prerendered — so uf's request handler is
mounted behind Vite's, and both are answered. Anything you set under vite.preview
in uf.config.js — proxy, https, headers, cors — is in effect, because
this is Vite's server.
--port PORT binds that port or fails, the same rule uf dev follows.
--host HOST needs a non-empty dev.allowedHosts, because Vite validates a
preview's Host header against it and a preview bound to a routable address
with nothing allowed would refuse every request. --mode MODE chooses the
.env files, production by default.
For a project that emits no server — build.staticBuild, or a
rendering.modes that allows only ssg — nothing is mounted behind the file
server, and that is the point rather than a limitation: what a static host does
with the output directory is exactly what this then does with it, so the
preview is right about the deployment that is actually happening.
uf start
Serves the same build through uf's own server, with no bundler in the process. This is what a deployment runs: a host serving a built application should not need the tool that built it.
It answers exactly what uf preview answers — one request handler, two front
doors — so a route that works when you check it works when you deploy it.
Static files from dist/ first, then route handlers, then a render for
anything the build did not prerender.
Binds 0.0.0.0:3000, and reads HOST and PORT from the environment, which
is how a container or a process manager says which socket to take. --host and
--port override both, and the port you name is the port it binds or it does
not start.
The .env files are read when the server starts, in production mode unless
--mode says another, so a value the server reads through process.env can be
changed without rebuilding. A variable already set in the environment — which is
how a container or a secret manager provides one — always wins over a file. See
Environments.
A project whose build emits no server is refused, by name, rather than
served: build.staticBuild says the build leaves documents behind, and a
uf start that quietly served them would be uf answering requests the real
host answers. Deploy the output directory to any static host, or check it with
uf preview.
uf build --adapter TARGET
Writes .uf/deploy/TARGET/: everything the target needs and nothing that is
still in your checkout — no node_modules, no source, no uf. Five targets
are implemented. Four of them run an application and share every line of it;
the fifth runs none:
| Target | What you get | What runs it |
|---|---|---|
node | handler.js, server.js, chunks/, static/, package.json | cd .uf/deploy/node && node server.js |
container | the same, plus Dockerfile and .dockerignore | docker build -t NAME .uf/deploy/container |
edge | handler.js, worker.js, wrangler.json, chunks/, static/ | cd .uf/deploy/edge && npx wrangler deploy |
serverless | handler.js, lambda.js, chunks/, static/, package.json | zip it, upload it, set the handler to lambda.handler |
static | your build, file for file, and nothing else | upload it to any static host |
handler.js is the seam and is identical work in the four that have one: it is
@uniflowed/server/fetch's handler over your server bundle, it touches no
filesystem, and it is the same function uf preview and uf start answer
through. An adapter is the file beside it and an answer for where static/
lives — not a second application.
static has no handler.js at all, because a static host runs no
application — see static below, whose whole implementation is the
refusal.
The one thing handler.js does say per target is what that target can do,
because two of the answers differ:
| Target | A body reaches the client as it is produced | The process outlives the response |
|---|---|---|
node, container | yes | yes |
edge | yes | no — the isolate may be torn down once the response is out |
serverless | no — a Lambda response is a JSON value, so the body is read to the end first | no |
A route handler that returns an event stream, takes a WebSocket, or queues work
asks through those, and is refused by name rather than by a connection nobody
serves: an event stream on serverless fails where the handler builds it
instead of timing out, and a WebSocket upgrader or an in-process queue handed
to a target that cannot keep it fails where the host is wired instead of on the
first connection. Routing has the whole table and the API.
The difference from uf start is what the host must already have. uf start
reads dist/ and .uf/build/server and needs the project's node_modules
around them, because the server bundle leaves react and react-dom as bare
imports. An adapter's output links them in, so the directory is the whole of
what has to travel.
No server adapter's output has ever been deployed. uf is developed in a
sandbox with no cloud credentials, no Docker daemon and no ability to bind a
socket. What its tests establish is that each directory has the file set the
platform documents, that the emitted entry answers when it is driven in
process, and that all four answer identically — byte for byte, the same as
uf start, including a loader's redirect(), whose whole answer is a
Location header. None of that is a deployment. The first person to run
wrangler deploy or upload the zip is finding something out, and
#391 is where to say what.
static is the exception, and only because there is nothing to deploy to: a
directory of files is what every static host serves, and uf's own
documentation site is built through this target in the test suite.
node and container
cd into the copy and node server.js; --port and PORT choose the port,
--host and HOST the address, the same way uf start reads them.
container writes exactly what node writes, plus a Dockerfile and a
.dockerignore. The Dockerfile is a template and its first line says so.
It has no build stage — the build already happened and the directory is its
result — and nothing to install, because the whole claim of an adapter's output
is that it needs no node_modules. It pins node:24-alpine, runs as the
node user, and exposes 3000. uf does not build the image, does not tag it,
does not push it, and does not keep the base image current: it is a file in
your output that you are expected to read and edit.
edge
The target is Cloudflare Workers, and the wrangler.json beside
worker.js is what makes the directory one. You need Wrangler
installed and a Cloudflare account authenticated with it; uf runs neither.
Three lines of that file are load-bearing, and it is yours to edit once you have read them:
"compatibility_flags": ["nodejs_compat"].handler.jsimportsnode:async_hooks, because@uniflowed/server's request context is anAsyncLocalStorage— that is what makesheaders()andcookies()work without threading a request through every component. Without the flag the script does not link at all. It is the only Node built-in the bundle imports, which a test asserts, so nothing else about your compatibility date is uf's business."compatibility_date": "2024-09-23"— the earliest date at whichnodejs_compatprovides that module. It is pinned rather than set to the day of your build, because a date ahead of the runtime you deploy onto is an error from Wrangler and a build whose output changes with the calendar is a build nobody can reproduce. Bump it yourself when you want newer runtime behaviour."run_worker_first": true, with"binding": "ASSETS"and"not_found_handling": "none". Cloudflare's default is to serve a matching asset without invoking your script; uf asks for the asset from inside the Worker instead, so the file-then-handler-then-render order is uf's own and is the same orderuf startuses. A miss comes back as a 404 the Worker falls through, so the 404 a visitor sees is your_uf.not-foundpage rather than Cloudflare's.
React is linked from its server.edge.js build for this target, so a document
comes out of renderToReadableStream rather than the Node renderer. That is a
different code path from every other target, and the test that says all four
adapters answer identically is the one that covers it.
What uf does not do: create the Worker, upload the assets, configure a route or
a custom domain, add a KV namespace or any other binding, or set a secret. Add
your own bindings to wrangler.json after reading it — it is regenerated on
every build, so keep a copy or commit a wrangler.jsonc of your own and point
main at the generated worker.js.
after() is different here, and only here. A Worker has no line at which
the last byte went out, so settle is handed to ctx.waitUntil: an after()
callback begins once the response has been decided rather than once it has
been sent. For a streamed document those are a document apart.
serverless
The target is AWS Lambda, and the event format is payload format 2.0 — what a Lambda Function URL always sends and what an API Gateway HTTP API sends by default. An API Gateway REST API sends format 1.0 and an ALB sends its own shape; neither is supported, and an event that is neither is refused with a message saying which format the handler reads rather than answered from fields that happen to be undefined.
Zip the directory, upload it, and set the function's handler to
lambda.handler on a Node.js 20 or later runtime. uf does not create the
function, does not zip it for you, does not configure a Function URL or an API
Gateway, and writes no infrastructure-as-code of any kind.
Two limits are worth knowing before you choose this target:
- The response is buffered.
createFetchHandleranswers a document as a stream so a<Suspense>fallback reaches the browser first; a Lambda response in this format is a JSON value, so the whole body is read before the invocation returns and none of that streaming survives. Response streaming exists —awslambda.streamifyResponse, on a Function URL whose invoke mode isRESPONSE_STREAM— and uf does not implement it, because the wrapper is a global the managed runtime injects and nothing outside a real invocation can drive it. AWS's 6 MB response payload limit applies to what comes back. static/is inside the package. The function answers a prerendered document and a hashed asset the moment it is uploaded, with no other infrastructure existing — which is the shape that works, not the shape to keep. Every byte is then billed as invocation time and counted against the package limit. Put CloudFront or S3 in front of it and the application half answers alone, exactly as it does on a Worker.
Cookies are handled where the format puts them: the event's cookies array
becomes the request's Cookie header, and every Set-Cookie a response sets
comes back in the result's cookies rather than in headers, because
iterating headers would join two cookies into one field nothing can parse
apart. A response body whose media type is not text comes back base64-encoded.
static
The output is your build — dist/, file for file, at the top of
.uf/deploy/static/ rather than inside a static/ beside a server. There is
no handler.js, no server.js and no package.json, because a static host
serves whatever is in the directory and a file the application never mentions
is a URL you did not mean to publish. Upload it to a CDN, an object store, or
anything else that returns files; uf names no host, because naming one would
be uf choosing a hosting company on your behalf.
The implementation is the refusal. uf build has always written dist/,
so this target adds no output. What it adds is that a project a static host
cannot serve is now rejected by name instead of published with holes in it:
| What | Why a file is not it |
|---|---|
a _uf.route.js | it answers a request, and a POST has no file behind it |
a _uf.middleware.js | it runs once per request, before the route resolves, and nothing runs on a static host |
a route the prerender wrote no document for — parameters and no generateStaticParams | there is nothing to upload for it |
a "use server" export the browser can call | an action is a POST back to the application, at the page's own URL |
Each finding names the URL, the file it is in, and what to do instead — which
is usually --adapter node, since the application is the same file in all
four server targets and the project does not have to change to use one. One
prerendered document is enough for a parameterised route: a
generateStaticParams that named three slugs wrote three files, and whether it
named every slug is a question about your data that no build can ask.
The two that are not written
bun and deno are names in DeployAdapter and nothing else. Asking for one
is an error that says which and why, and names
#391.
| Target | Why not |
|---|---|
bun, deno | The node output already runs unchanged on both. An adapter here is Bun.serve or Deno.serve in place of node:http, and it is worth writing only once a benchmark shows the native server beating node:http under the same handler — otherwise it is a directory with a different name on it. Nobody has run that benchmark. |
app.runtime.deploy.adapters lists what this toolchain can actually produce,
and uf explain build names the adapter a build will run.
uf build --compile
Writes dist/NAME, a single executable file that serves the application, in
addition to everything uf build already wrote. It answers with the
prerendered documents, the hashed assets, the route handlers, and a fresh
render for any route that could not be prerendered — all of it inside the file,
so the file can be copied into an empty directory on an empty machine and
started. --port and PORT choose the port, --host and HOST the address,
the same way uf start reads them.
The difference from uf build --adapter node is one more step down: that
writes a directory and needs a JavaScript runtime to run it, and this carries
the runtime too and needs nothing — which is also the trade, because the copy
inside it is fixed at build time and changing a single asset means building
again.
Producing one needs bun on PATH, because Bun's runtime is what gets embedded;
running one needs nothing. A project that imports a native .node addon cannot
be a single file, and the build says which addon and which importer rather than
producing something that fails on its first request — but an addon loaded
through a runtime string, with process.dlopen or require(variable), is
invisible to any bundler and will still compile and still fail when a request
reaches it. Building for a platform other than the one you are on is
#310.
uf doc
Generates api.md from JSDoc attached to exported Flow declarations. The
source is parsed with Meta's Flow parser, so Flow-only syntax is read directly.
--out DIR writes somewhere other than docs/api; --json reports without
writing.
uf run SCRIPT [ARGS...]
Runs a task from uf.config.js, and everything its dependsOn reaches,
forwarding everything after the script name. The task's shell has the project's
.env values in it, as every command uf runs does; --mode MODE, before the
script name, chooses which files those came from. An option uf run also has
is reached past --.
Dependencies with no path between them run at once — four at a time, or
one per core when the machine has fewer, and -j N / --concurrency N says
otherwise. -j 1 is one at a time. While more than one task can be running,
each line of output is written behind the name of the task that produced it;
the task you asked for runs after everything it depends on, so it is alone and
its output is untouched.
A task that declares inputs is
cached: uf hashes the files those patterns match, and a second run whose
files all hash the same is answered from .uf/cache/task — output and all,
so a replayed run reads like the run it is replaying. A task that declares
no inputs runs every time. That is the default and it is not a soft one:
uf cannot see what sh -c opens, and a cache that guessed would fail by
reporting a check as passed without looking.
--force runs everything whatever the cache holds, and still records what it
finds. --why prints, for every task, the reason it ran or was replayed —
packages/core/index.js changed, declares no inputs, so it always runs,
dist/app.js is missing.
uf exec PACKAGE [ARGS...]
Runs a package's binary, forwarding everything after the name to it and
exiting with its status. Also spelled ufx.
An installed binary — anything in this project's node_modules/.bin — runs
without ceremony. A package uf implements itself, such as @uniflowed/create,
runs in this process.
A package the project has not installed is refused, and uf exec says so
and exits non-zero. Fetching an unpinned name from a registry and executing it
is the most dangerous thing a package manager does, and uf already refuses to
run a dependency's install scripts without being asked; --yes is how you ask
here, and it delegates to whichever package manager the project uses (npx --yes, pnpm dlx, yarn dlx, bunx).
Correctness
uf check
Type-checks the project with Flow. --json for the machine-readable report.
--fix and --fix-unsafe are uf lint's, and mean the same thing here: they
apply the lint fixes. Type inference has no fix catalogue — a type error is
not something uf knows how to rewrite.
uf lint
Runs uf's rules and Flow's built-in lints. --json for the report.
| Flag | Meaning |
|---|---|
--fix | Rewrite the files, applying every fix that cannot change what the program does |
--fix-unsafe | Also apply the fixes whose correctness rests on something the rule could not check |
--json | The whole report on stdout, machine-readable |
Without a --fix flag nothing is written. That is the --check-like use, and
it is what CI and a git hook run.
What "safe" and "unsafe" mean here
The catalogue lives in crates/uf_cli/src/fix.rs and carries the distinction
itself, so every caller — the command line, uf prepare, and the language
server — draws the line in the same place.
A safe fix is one where the program after the edit means what the program
before it meant: the replacement is a synonym the language already treats as
the original. bool → boolean (flow/deprecated-type) is the one uf has.
Nothing is being decided on your behalf, so --fix applies it without asking.
An unsafe fix is the edit the rule is asking for, spelled correctly, whose
correctness rests on something the rule could not check. export let count = 0 → export const count = 0 (flow/non-const-var-export) is one: the file
still parses and still formats, and it throws at run time if anything reassigns
the binding. import.meta.hot.accept(…) → import.meta.hot?.accept(…)
(vite/hot-needs-optional-chaining) is the other, and it is the clearer
illustration — in a dev server the two spellings are the same program, and in a
build they are not, which is the bug being fixed and is still a change. Neither
is a guess at intent — a fix uf cannot spell at all is in neither tier — but
both can change what the program does, so they wait to be asked for by name.
Read the diff before committing it.
Most rules have no fix in either tier, and that is deliberate rather than
unfinished: flow/unclear-type has three plausible answers, which is none, and
the module header lists the rest with the reason for each.
What --fix guarantees
- What is written parses. Every candidate goes back through the formatter, which prints from a syntax tree and fails on text the parser refuses. A file whose fixed form does not parse is left exactly as it was and named in the report.
uf fmt --checkstill passes. A file the formatter was content with is reprinted around the edit, because four bytes is enough to push a line past the print width. A fileuf fmtalready wanted to change is written as the edit left it — imposing a layout is a different command.- Twice is once. Each file is linted, fixed and linted again until its text
stops changing, so a second
uf lint --fixwrites nothing. - Two fixes over the same bytes cannot corrupt each other. One of an overlapping pair is applied and the other is dropped; the loop above lints the result and plans the dropped one again against text that now exists.
uf lint --fix does not format. The answer to uniflowed/no-tabs is uf fmt,
and findings like it are counted in the report rather than quietly reformatted.
Exit status
The status describes what is left, never what was done. A run that fixed forty findings and left one error fails; a run that fixed nothing and left none passes.
| Status | Meaning |
|---|---|
0 | No errors remain. Warnings may — they do not fail uf lint with or without --fix |
1 | Errors remain, a file could not be read, or writing a fix failed |
There is no third status for "fixed everything": the two the command has are
the two a shell script branches on, and uf lint --fix; uf lint cannot
disagree with itself, because the report --fix prints comes from linting the
project it has just rewritten.
uf fmt
Formats the project. --check reports what would change and exits non-zero
instead of writing.
JSON, CSS and TypeScript go to fmt.nonFlow.formatter — Biome by default —
which uf runs rather than ships. A formatter uf chose and the project does not
have is reported, with the files it left alone, and does not change the exit
code; one the project named in uf.config.js is an error, because the project
asked for it.
uf fmt stays a separate command rather than folding into uf check the way
some toolchains fold their formatter into one pass. Three commands where
another tool has one is the trade uf makes on purpose: uf fmt --check is the
cheapest of the three and the one a hook wants first, and a uf check that
also rewrote files would be a type checker that writes.
uf test [PATH...]
Runs the test suite. A PATH argument keeps only files whose path contains it.
| Flag | Meaning |
|---|---|
-t PATTERN | Only tests whose fully qualified name contains PATTERN |
-j N, --threads N | At most N files at once; defaults to one per core |
--bail[=N] | Stop after N failures; N defaults to 1 |
--retry N | Re-run a failing test up to N more times |
--watch | Re-run affected tests when a source file changes |
--watch-interval MS | How often --watch looks for changes |
--list | Print what would run without running it |
--json | The whole report on stdout, machine-readable |
--mode MODE | Run in that mode instead of test, which chooses .env.<mode> |
--coverage | Measure which Flow lines the suite executed. Node only |
--coverage-reporter FORMAT | text, lcov or cobertura; repeat for more than one. Overrides test.coverage.reporters |
--coverage-dir DIR | Where the reports go, overriding test.coverage.directory |
--reporter junit | Write the results as JUnit XML. Needs --reporter-outfile |
--reporter-outfile FILE | Where --reporter writes |
Coverage is reported against the Flow source, mapped back through the
transform's own source map, and a threshold in uf.config.js that is not
reached fails the run. --watch and --coverage cannot be combined.
uf prepare
Runs the checks and code generation a commit should not go without. Five steps, in order, and every one of them does something:
| Step | What it does |
|---|---|
discover-staged-files | Asks git for the files in the index, and narrows the two checks to them |
generate-router-types | Writes router.js from the reserved page files |
generate-server-action-types | Writes server-actions.js: every callable server action, keyed by module#export, with its real type read off the declaring module |
run-lint | uf lint over the staged files |
run-format-check | uf fmt --check over the staged files |
With --fix those last two write instead of only reporting: the lint step
applies uf lint's safe fixes and the format step formats. Safe only,
never the unsafe tier — a hook that runs on every commit is the last place an
edit that can change what the program does should arrive unasked-for, and
uf lint --fix-unsafe is how you ask.
A --fix run that changed a file fails, even when nothing is wrong any
more. What it wrote is in the working tree and not in the index, so the
commit git is about to make is not the code uf just fixed. Failing stops that
commit and leaves you a diff to read and stage; uf prepare does not stage it
for you, because adding content to a commit whose message is already written is
how a hook gets uninstalled.
The record of the run goes to .uf/prepare.json, including the steps that did
not run: a generation failure stops the run, and the checks after it are
recorded as not-run rather than as anything that could be read as a pass.
Both generated files are checked whether or not they are staged — a scaffolded project git-ignores them, so they never are, and a commit hook that writes a file its own linter rejects is a hook nobody keeps.
Outside a git working tree there is no staged set, so every file is checked and the run says why. With git and an empty index there is nothing to check, and the two checks are skipped.
uf prepare reads the working tree, not the staged blob. A file that is half
staged is checked as it is on disk; uf does not stash the unstaged half the way
lint-staged does.
Messages
Two commands over @uniflowed/i18n's catalogue: one that takes it out to a
translator, one that reads it back. They share a file, and the shape of that
file is the same in both directions, so a vendor who fills in the extraction
has produced something uf i18n merge reads with no conversion step in
between.
The walk is Rust rather than the package for the reason the formatter and the
checker are: it is repeated, repository-wide, CPU-bound work. The half that is
a parse of one message stays in @uniflowed/i18n, and neither command
implements MessageFormat 2 — see
the package reference for what that division means.
uf i18n extract
Writes every message the project declares as one JSON file:
{
"format": "uf-i18n-catalogue/1",
"sourceLocale": "en-US",
"locale": "en-US",
"messages": {
"greeting": {
"source": "Hello, {$name}!",
"translation": "Hello, {$name}!",
"parameters": { "name": "string" },
"declaredAt": "src/messages.js:10",
"digest": "2f1c0b4a7d9e5a63"
}
}
}
The text is MessageFormat 2, unchanged from the source — uf copies the literal
and never parses it. translation starts equal to source, which gives a
translator something to edit rather than an empty box and is how uf i18n merge later tells a translated entry from one that came back untouched.
digest covers the source and the parameters together, and nothing else: a
message that moved to another file is not a message that changed.
--locale TAG names the locale the messages are written in. Without it uf
reads the project's defineCatalogue calls and uses the one literal tag they
name, and says so rather than guessing when they name none or several.
--out PATH writes somewhere other than i18n/<locale>.json. --json
reports on stdout and writes no file.
A message(…) uf cannot read fails the command and nothing is written.
A source that is not a literal or a module-level const holding one, a
parameter that is not one of the four kinds, a message declared under no name,
two messages under one key: each is reported with its file and line. A
catalogue quietly missing a message is invisible in the extraction, invisible
in review, and visible only to somebody reading English on a Japanese page.
uf i18n merge FILE
Reads a translated catalogue and writes the locale module defineLocales
loads — a // @flow module default-exporting the keys and their translations,
next to FILE and named for the locale, which is where
() => import("./ja-JP.js") already points. --out PATH puts it elsewhere;
--json reports and writes nothing.
uf extracts the project again first, because a translation round takes weeks and the English does not stop. Every returned entry is held against what its message says today:
| What uf found | What it does |
|---|---|
| The message changed since the file went out | Names it, writes nothing, exits non-zero |
| The key is no longer declared | Reports it, leaves it out |
| A key the file does not carry | Reports it as still to translate |
translation still equal to source | Reports it, leaves it out |
Only the first is a failure. The rest are what a translation in progress looks
like, and a key left out here is one translate falls back to the source for
and lists on the catalogue as untranslated.
What merging does not check is whether a translation's placeholders agree
with the message's parameters. That needs MessageFormat 2 parsed, and it is
checked by translate when the locale loads, against the real parser, in the
one place this repository implements it.
Dependencies
Five commands, all of them the project's own package manager. uf's resolver
reaches no registry yet, so each one detects whether the project is npm, pnpm,
Yarn or Bun and runs that — while keeping the three things a bare npm install
would skip: lifecycle scripts stay refused, a manifest that declares scripts of
its own is rejected before anything is fetched, and uf.lock and the
content-addressed store under .uf/store are rewritten from the manifests the
manager just changed.
Every one of them reports which manager ran, what named it, and the exact command it spawned. A command that delegates without saying to whom is the black box red line 7 forbids.
uf install
Installs dependencies with the host's package manager, and refuses lifecycle
scripts while doing it — the manager is passed --ignore-scripts, and a
manifest that declares scripts of its own fails before anything is fetched.
On a terminal, the phases are drawn as they happen and redrawn where they stand: resolving, fetching, linking, auditing, each with what it is working on, how many it has done and how long it has taken. Off a terminal — a pipe, a file, a CI log — nothing is redrawn and nothing is animated.
Afterwards it reports what changed rather than a count: the packages added, removed, updated and moved between the lockfile before the install and the one after, the time each phase took, which manager ran and what named it, and what to run next. An install that changed nothing says so in a line and stops.
The manager's own output is passed through untouched, on the stream it wrote it to. A failed install still says why in the manager's words.
It also records what the workspace resolved to, in .uf/install.json: the
resolver, the lockfile and store manifest it wrote, how many packages and store
entries there are, and the runtime engine, acquisition and hosts the config
implies. That file is a record and not an input — nothing reads it back — and
it is what uf upgrade used to write under a name that promised a new uf
(#424).
uf install --frozen-lockfile
What CI runs. Installs exactly what the lockfiles pin, and fails rather than
resolving a lockfile that has drifted from the manifests: npm ci, pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile.
Two lockfiles are checked, and they catch different drift. uf.lock is
derived from the workspace's manifests, so uf checks it first and fails when
rewriting it would change it — putting back the file it was handed, so the
failing step can still print a diff of the real one. The manager's own lockfile
is then checked by the manager, which is the case where package.json has not
moved but package-lock.json no longer pins what it asks for.
uf add [--dev|--optional|--peer] SPEC...
Adds dependencies: resolves them, installs them, and writes both the manifest and the lockfile.
A specifier reaches the manager exactly as written, so react, react@^19,
react@next, react@npm:preact and ./packages/ui all mean what they mean
there. It is passed as a single argument and never through a shell, so a range
with a space in it — uf add "react@>=18 <20" — stays one specifier. A
specifier that starts with - is refused before anything runs, because every
manager would read it as a flag.
--dev, --optional and --peer write devDependencies,
optionalDependencies and peerDependencies; the three are mutually
exclusive, and with none of them the package goes in dependencies. The
summary names the field the package actually landed in, read back out of
package.json rather than repeated from the flag.
In a pnpm workspace, uf add at the workspace root passes pnpm's
--workspace-root. pnpm refuses to add to a root without it —
ERR_PNPM_ADDING_TO_ROOT — and the advice it gives, to run the command again
with -w, was advice nobody could follow through uf, which never passed that
flag. The root is where a workspace's development dependencies belong, so the
command now works there.
It is passed only where the project root is the workspace root. Run inside a
member package, uf add resolves to that member and the flag is not passed, so
the dependency still lands where you are. npm, Yarn and Bun add to a workspace
root without being asked twice, and none of them is given the flag.
Running it twice changes nothing the second time, and says so in a line.
uf remove NAME...
Takes packages out of every dependency field that lists them, out of the
lockfile and out of node_modules. A name the manifest never listed is not an
error: the project ends up the way it was asked to be either way.
uf update [PACKAGE...]
Two different things go by this name, and uf update does both.
With no flag it is the manager's own update: the lockfile moves to the newest
versions the manifest's ranges already allow, and package.json is not
touched. Then uf reports the other half — every dependency whose newest
published version the declared range does not allow:
outside the range
package declared newest step manifest
react ^18.2.0 19.2.8 major .
react ^17.0.0 19.2.8 major packages/ui
vitest ~1.0.0 5.0.0 major .
classnames 2.2.6 2.5.1 minor .
› 4 dependencies are newer than their range allows
› uf update --latest rewrites the ranges; --minor and --patch cap the step
--latest rewrites those ranges to the newest published version and installs.
--minor and --patch cap how far a range may move — --minor holds the
major, --patch holds the minor too — which is the difference between a
Friday-afternoon update and a Monday-morning one. --dry-run prints the table
and changes nothing.
A rewrite keeps the comparator the project chose: ^1.2.3 becomes ^2.0.0,
~1.2.3 becomes ~2.0.0, and a pin stays a pin. Only the range moves in the
file — indentation, key order and the trailing newline are left exactly as they
were.
Prereleases are not offered unless the range's own base is one: a project on
^18.2.0 is not asking to be moved to 19.0.0-rc.1.
Four range shapes are rewritten — ^1.2.3, ~1.2.3, 1.2.3, >=1.2.3 — and
everything else is counted and left alone. workspace:*, catalog:, npm:,
file:, a git URL and a range with more than one comparator each say something
a single comparator cannot restate, and a tool that rewrote one would be
throwing away a decision somebody made on purpose.
Reading which versions exist is the one thing here uf does not delegate, because
no manager will answer it: npm outdated, pnpm outdated and yarn npm outdated each report a different thing in a different shape, bun has no JSON
at all, and none of the four rewrites a range. uf asks the registry the same
question the manager's resolver would — a packument, over the registry in your
config — and the install that follows is still the manager's. A registry that
cannot be reached is reported next to the packages it could not answer for; it
does not fail the command.
This is not uf self-update, which replaces the uf
binary rather than moving your dependencies.
uf pm approve-builds [NAME...]
Lists every package in the installed tree that would run code when it installs, with the hooks it declares and whether this project has approved it:
manager pnpm
approvals in pnpm.onlyBuiltDependencies
package version runs approved
esbuild 0.24.0 postinstall no
sharp 0.33.5 postinstall no
› 2 packages would run code at install time and are not approved, so uf does not let them run
› uf pm approve-builds <name>... records the ones you have read
Naming one records it. --dry-run says what would be approved and writes
nothing. A name that is not in the tree is refused with the names that are — on
a security command a typo that silently does nothing is worse than an error.
The approved set lives in the root package.json, in the field your package
manager already reads (pnpm.onlyBuiltDependencies, trustedDependencies,
dependenciesMeta), so the manager is what enforces it. npm and Yarn 1 have no
per-package control and are told so plainly. docs/security.md has the full
reasoning, including why an empty list still means "none"
(#495).
Approving does not install; uf install is what runs the builds.
uf patch PACKAGE
Opens a copy of a dependency somewhere you can edit it, and prints the directory:
uf patch left-pad
Edit what is in there, then:
uf patch --commit /var/folders/.../left-pad-1.3.0
which turns your edits into a patch file, records it in the manifest, and installs. Every install after that reapplies it.
pnpm and Yarn 2+ only. npm, bun and Yarn 1 have nothing equivalent, and uf
refuses by name rather than handing the manager a subcommand it has never heard
of — a passthrough fails too, with Unknown command: patch and a suggestion to
read npm help, which tells you nothing. The refusal names the two managers
that can and points at patch-package, which uf does not install for you:
this is the one command whose entire purpose is editing somebody else's code,
and it is not the place for uf to add a dependency the project did not choose
(#494).
The open half changes nothing about your project, so it prints the three
delegation rows and then gets out of the way — the directory path is the answer
and it should be the last thing on the screen. The commit half installs, so it
refuses lifecycle scripts, rewrites uf.lock, and reports what moved in the
tree, like every other command that writes.
uf catalog
Prints the versions this workspace shares — every package more than one manifest declares — and, underneath, every package whose manifests disagree:
shared
package range declared in
eslint differs 2 packages
react differs 3 packages
disagreements
package manifest range
eslint . ^9.0.0
eslint packages/ui ^8.57.0
react . ^18.2.0
react packages/api ^17.0.0
react packages/ui ^18.2.0
! 2 packages declared at more than one range
› uf catalog set eslint <range> makes every manifest agree
That second table is the bug a catalogue exists to prevent, and nothing else reports it.
uf catalog set NAME RANGE
Writes one range into every manifest that declares NAME, then installs.
--dry-run prints the table and changes nothing. Only the range moves in each
file; indentation and key order are left as they were.
Why this and not pnpm's catalog:. pnpm names a version once in
pnpm-workspace.yaml and lets a package write "react": "catalog:". No other
manager has the concept — npm, yarn and bun read catalog: as a specifier they
cannot resolve and refuse the install. That left uf two options, and both were
worse than this one: a passthrough to pnpm does nothing in four projects out of
five, and resolving catalog: for the other managers means uf rewriting
everybody's package.json on the way into an install and putting it back
afterwards — uf standing between the manifests and the installer, and a
workspace full of manifests nobody wrote if you interrupt it.
So uf does not invent a fifth catalogue. It offers what catalogs are for, in a
form all five managers already understand: one place to change a version, and a
check that every package agrees. What you give up is the indirection — a
manifest here says ^19.0.0 rather than catalog:. What you get is a file that
says what will be installed, on every manager, and one command instead of an
edit and a hope.
A project already using pnpm's catalogs keeps them: uf counts the catalog:
declarations, reports them, and leaves pnpm to resolve them, because pnpm can
(#496).
uf why NAME
Explains why a package is in the tree, answered by the manager out of its own
lockfile — npm explain, pnpm why, yarn why, bun why — so the chain
printed is the chain that was actually installed. It writes nothing at all, not
even uf.lock.
uf ls [NAME...]
Lists the installed tree, through the same manager — npm ls, pnpm list,
yarn list on Yarn 1, yarn info --all on Yarn 2+, bun pm ls. A package
name narrows it.
uf audit [NAME...]
Audits the installed tree against the registry's advisories: npm audit,
pnpm audit, yarn audit, yarn npm audit on Yarn 2+, bun audit.
uf search TERM...
Searches the registry. npm and pnpm can; Yarn and bun cannot, and uf says so rather than running a manager the project did not choose:
error: bun has no `search`: npm and pnpm can search the registry;
`uf exec --yes npm search` runs npm's without changing what this project
installs with
That refusal is the design rather than a gap. A tool that quietly reached for another manager would resolve against that manager's registry configuration and could write its own lockfile, which is the one thing a manager-agnostic front end must not do.
Each of the three takes package names and no flags. A manager's own flags are
its own surface — reached with uf exec — because uf cannot know which of them
only read: pnpm audit --fix rewrites a lockfile.
uf uninstall is uf remove, which npm and pnpm both spell that way.
uf clean [--deps] [--dry-run]
Removes what a rebuild would write again: the build's output, the documentation
site's output if it is elsewhere, and .uf/ — uf's own per-project state, which is the
transform and check caches and the resolved config.
Not node_modules unless --deps says so. The line is the network:
everything removed by default, the checkout can produce again on its own, and a
command that deleted the install because somebody wanted their dist/ gone
would turn a two-second mistake into a two-minute one.
No flag removes a lockfile. uf.lock and the manager's own are inputs —
checked in, reviewed, and the reason an install is reproducible — and no amount
of cleaning should make the next install resolve something different from the
last one.
It prints what it removed and how much. --dry-run prints the same table and
removes nothing.
A configured outDir outside the project is refused rather than removed:
build.outDir comes from uf.config.js, and a command that deletes must not
be one typo away from taking a directory nobody named.
Environment
uf use TOOLCHAIN
Switches the active uf toolchain: uf use uf@0.1.0. It takes a uf@<version>
and nothing else — the host a project runs on is host.runtime in
uf.config.js, not this command, and a name that is not uf is refused rather
than installed as one.
A version this machine already has is activated: uf, ufr and ufx in
~/.local/bin are relinked at it, all three together. A version it does not
have is downloaded and verified first, by the same installer
uf self-update uses — so uf use uf@0.9.9 for a release
that does not exist fails and installs nothing, where it used to succeed by
copying the running binary into a directory named after a version it was not
(#534).
The exception, and the only one, is the version uf already is: a uf built
from source can install itself into the store under its own version, with no
network. runtime.json beside the runtime records which of the three it was —
release, mirror or running-binary.
uf env doctor
Reports which JavaScript hosts are installed on this machine, which one uf would use, and why.
uf env use PROFILE
Records the profile every later command runs in, in .uf/profile. uf env use staging makes .env.staging and .env.staging.local the mode files
for uf dev, uf build, uf test and the rest, until you change it or pass
--mode. The name becomes the end of a file name, so it may hold letters,
digits, _, - and .; local is refused, because .env.local is already
the file that overrides every mode. See Environments.
uf env install
Installs the runtimes and package managers uf.config.js declares, into a store
shared by every repository on the machine, and links them into this one. Nothing
is installed globally and PATH is not changed. See
Environments.
uf env list
Lists what the project declares beside what the store holds.
uf env exec COMMAND
Runs a command with this project's toolchain in front of PATH, and exits with
whatever the command exited with.
uf env gc
Deletes store entries no repository is using. --dry-run says what would go and
removes nothing.
uf self-update
Replaces this uf with the newest release: resolves it, downloads
uf-<target>.tar.gz, checks it against the sha256 published beside it,
refuses an archive whose members escape their own directory, unpacks it into
the runtime store and relinks uf, ufr and ufx.
All six of those are the installer's —
infra/cloudflare/setup-assets/install.sh, the script
curl -fsSL https://setup.uniflowed.dev | sh runs — embedded in the uf binary
at build time and piped into sh. uf does not carry a second downloader: a
checked download written twice is a checked download to get wrong twice, and
the shell one is the copy every new user's first command already runs.
Embedding rather than fetching it means a self-update executes only what this
uf was built from, and the only thing crossing the network is an archive whose
digest is checked.
It reads no project, so a uf.config.js uf cannot parse is not a reason to be
unable to install a uf that can. UF_VERSION pins a version instead of
resolving the newest, UF_RELEASE_BASE points at a mirror, and
UF_INSTALL_ROOT and UF_BIN_DIR move the store and the links — each meaning
here exactly what it means to the installer.
uf upgrade was the name this was meant to be, and never what it did: it
re-read the workspace and wrote a plan file, fetching nothing and replacing no
binary (#424,
#499). It has been retired.
Typing it now names the three commands it could have meant —
uf self-update for a newer uf, uf update for newer
dependencies, and uf install, which is where re-reading the
workspace and recording the plan now lives, in .uf/install.json.
Publishing
uf release BUMP
Plans the next version — patch, minor, major or alpha — writes its
CHANGELOG.md section from the commits since the last release tag, and writes
the plan to .uf/release.json. It does not move the version in the workspace
and it does not tag: tools/release/bump-version.sh is the first, and the tag
is a step of its own.
The version it plans is the one compiled into the binary running it, which is
the right source — the binary that cuts a release is the binary being released
— and it means an out-of-date binary plans a version the tree has already
published. Rewriting that version's section replaces a release's notes with a
later release's commits, so uf release refuses instead: a version that is
already tagged is refused outright, and a section that exists but has no tag is
a release being prepared and is refused until --force says the rewrite is
meant (#457).
It also names the commits in the range whose subject carries no (#NNN).
GitHub writes that number when it squash-merges, and a commit that did not get
one is invisible to everything downstream that identifies a change by its
number — so those are the lines to place by hand, and the ones
tools/ci/changelog-covers-the-release.sh asks to see cited by their summary or
their hash (#443).
uf publish
Publishes the package to the registry.
Exit codes
| Code | Meaning |
|---|---|
0 | Success; for fmt --check, nothing would change |
1 | The command ran and reported a problem: a failing test, a lint error, a type error, a file that needs formatting |
2 | uf could not run the command at all: an argument it cannot parse |
uf exec and uf env exec are the exceptions, and deliberately: both hand
control to another program, and both exit with whatever that program exited
with. A 2 from ufx tsc is tsc's 2, not uf's — wrapping it in uf's 1
would make every code a child can produce indistinguishable from every other,
which is the whole reason a script reads one.
A missing uf.config.js is not one of these — a project without one gets the
defaults, and every read-only command works. Two cases that should be 2 are
1 today, and the reason is written down rather than rounded off: a
uf.config.js uf cannot parse, and a missing @uniflowed/vite or
@uniflowed/host. Every command returns the same error type, so telling "could
not run" from "ran and found a problem" needs a distinction the command layer
does not make yet.